mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-07-26 14:38:39 +02:00
Compare commits
125
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84632c8a32 | ||
|
|
df717fc87f | ||
|
|
2b9f5c866b | ||
|
|
2de81130a1 | ||
|
|
2942d703ee | ||
|
|
3ef6c345e4 | ||
|
|
0a4052dec2 | ||
|
|
ef4bb6c7a8 | ||
|
|
d9c38d1592 | ||
|
|
a7227acbc3 | ||
|
|
bd5229941b | ||
|
|
0583ce617e | ||
|
|
7462eb3bc4 | ||
|
|
18e15ff056 | ||
|
|
4f6468c23a | ||
|
|
31531197a2 | ||
|
|
479de5f067 | ||
|
|
03240415b8 | ||
|
|
f8a6b6a936 | ||
|
|
28e50dd461 | ||
|
|
f06afbd08c | ||
|
|
860c7aac80 | ||
|
|
8bab4ae383 | ||
|
|
69171e5732 | ||
|
|
31abaa019f | ||
|
|
ad57a90e4d | ||
|
|
925e8fd17e | ||
|
|
7b857f04d4 | ||
|
|
453744a637 | ||
|
|
062d4dc069 | ||
|
|
21bd98b367 | ||
|
|
ccd206e95d | ||
|
|
bca5421dbc | ||
|
|
20085c5eed | ||
|
|
a8c66ff808 | ||
|
|
b2d3edca81 | ||
|
|
4b7fa7a543 | ||
|
|
a018dcf085 | ||
|
|
091c1bcb9f | ||
|
|
d89c97ba1a | ||
|
|
56fe350104 | ||
|
|
eb552170e3 | ||
|
|
29d022adcc | ||
|
|
d9afdaf56f | ||
|
|
699976dcd8 | ||
|
|
acf85f4f09 | ||
|
|
8f6dd72df2 | ||
|
|
297eca6a86 | ||
|
|
b1805875b9 | ||
|
|
16b06b5eb8 | ||
|
|
dbbb5faa78 | ||
|
|
5482b30324 | ||
|
|
5bae5411c8 | ||
|
|
f75a8dfb86 | ||
|
|
716d07c74e | ||
|
|
7de0304a3d | ||
|
|
df54094221 | ||
|
|
3207841384 | ||
|
|
ae571d6b79 | ||
|
|
f6ee0bf8df | ||
|
|
d59f3ae0b6 | ||
|
|
89041c4141 | ||
|
|
aac77949b4 | ||
|
|
eb40f6e6e9 | ||
|
|
34b3910ff8 | ||
|
|
83fdf4fd65 | ||
|
|
ea6bb0a847 | ||
|
|
d85158bd70 | ||
|
|
22c5fee48e | ||
|
|
0890a44c0b | ||
|
|
4fd4cc5604 | ||
|
|
c73b71345a | ||
|
|
11f0af1ebd | ||
|
|
a532f988b0 | ||
|
|
bec2796771 | ||
|
|
feec7f7399 | ||
|
|
067c2f84dd | ||
|
|
69996a4346 | ||
|
|
80cda129d2 | ||
|
|
dd0ff04155 | ||
|
|
c69558aa7f | ||
|
|
8fefea0d94 | ||
|
|
0ddee6f44e | ||
|
|
feec1ab55a | ||
|
|
49500aecd6 | ||
|
|
894bf100d2 | ||
|
|
7ba810adb8 | ||
|
|
d10f33362e | ||
|
|
82e8fbba2b | ||
|
|
64325fe2a6 | ||
|
|
99217fee48 | ||
|
|
f12e64990c | ||
|
|
ef7bb9a9d8 | ||
|
|
5ac925e151 | ||
|
|
acc5bde33a | ||
|
|
b521f37ec1 | ||
|
|
379fa21571 | ||
|
|
1aa6a441f3 | ||
|
|
705bbaccc1 | ||
|
|
8365e06762 | ||
|
|
139a0b3412 | ||
|
|
ea6b21169e | ||
|
|
a262efe9da | ||
|
|
ed5fb09007 | ||
|
|
d3c6429fd6 | ||
|
|
371fcacc87 | ||
|
|
75666e523f | ||
|
|
592ea7c533 | ||
|
|
b860dfc791 | ||
|
|
96dde8cf31 | ||
|
|
b4a37fed28 | ||
|
|
ad3fee8907 | ||
|
|
94db55598a | ||
|
|
9687f47b6b | ||
|
|
af216d9b38 | ||
|
|
c7879c2bc0 | ||
|
|
7db503a2cf | ||
|
|
d72295a0e6 | ||
|
|
638da4400f | ||
|
|
af9a7aafae | ||
|
|
ab9f7fed64 | ||
|
|
b62ce27487 | ||
|
|
6d9e902fe7 | ||
|
|
a0faf3f7d4 | ||
|
|
32dfb3b40a |
@@ -22,6 +22,8 @@ kotlin {
|
||||
|
||||
android {
|
||||
|
||||
|
||||
namespace 'com.audiobookshelf.app'
|
||||
buildFeatures {
|
||||
viewBinding true
|
||||
}
|
||||
@@ -33,8 +35,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 89
|
||||
versionName "0.9.59-beta"
|
||||
versionCode 92
|
||||
versionName "0.9.61-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -78,6 +80,7 @@ configurations.all {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation project(':capacitor-android')
|
||||
@@ -121,7 +124,7 @@ dependencies {
|
||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||
|
||||
// Simple Storage
|
||||
implementation "com.anggrayudi:storage:0.14.0"
|
||||
implementation "com.anggrayudi:storage:1.5.4"
|
||||
|
||||
// OK HTTP
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
android {
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_1_8
|
||||
targetCompatibility JavaVersion.VERSION_1_8
|
||||
sourceCompatibility JavaVersion.VERSION_11
|
||||
targetCompatibility JavaVersion.VERSION_11
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ dependencies {
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-haptics')
|
||||
implementation project(':capacitor-network')
|
||||
implementation project(':capacitor-preferences')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capacitor-storage')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:dist="http://schemas.android.com/apk/distribution"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.audiobookshelf.app"
|
||||
android:installLocation="preferExternal" >
|
||||
|
||||
<!-- Permissions -->
|
||||
@@ -75,7 +74,7 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -16,11 +16,11 @@
|
||||
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/status-bar",
|
||||
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
|
||||
"pkg": "@capacitor/preferences",
|
||||
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/storage",
|
||||
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
|
||||
"pkg": "@capacitor/status-bar",
|
||||
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -12,8 +12,8 @@ import android.util.Log
|
||||
import androidx.core.app.ActivityCompat
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.audiobookshelf.app.data.AbsDatabase
|
||||
import com.audiobookshelf.app.data.DbManager
|
||||
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
@@ -51,11 +51,17 @@ class MainActivity : BridgeActivity() {
|
||||
// .detectLeakedClosableObjects()
|
||||
// .penaltyLog()
|
||||
// .build())
|
||||
DbManager.initialize(applicationContext)
|
||||
|
||||
registerPlugin(AbsAudioPlayer::class.java)
|
||||
registerPlugin(AbsDownloader::class.java)
|
||||
registerPlugin(AbsFileSystem::class.java)
|
||||
registerPlugin(AbsDatabase::class.java)
|
||||
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d(tag, "onCreate")
|
||||
|
||||
DbManager.initialize(applicationContext)
|
||||
|
||||
|
||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||
@@ -63,11 +69,6 @@ class MainActivity : BridgeActivity() {
|
||||
PERMISSIONS_ALL,
|
||||
REQUEST_PERMISSIONS)
|
||||
}
|
||||
|
||||
registerPlugin(AbsAudioPlayer::class.java)
|
||||
registerPlugin(AbsDownloader::class.java)
|
||||
registerPlugin(AbsFileSystem::class.java)
|
||||
registerPlugin(AbsDatabase::class.java)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioTrack(
|
||||
var index:Int,
|
||||
var startOffset:Double,
|
||||
var duration:Double,
|
||||
var title:String,
|
||||
var contentUrl:String,
|
||||
var mimeType:String,
|
||||
var metadata:FileMetadata?,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var audioProbeResult:AudioProbeResult?,
|
||||
var serverIndex:Int? // Need to know if server track index is different
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val startOffsetMs get() = (startOffset * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val durationMs get() = (duration * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endOffsetMs get() = startOffsetMs + durationMs
|
||||
@get:JsonIgnore
|
||||
val relPath get() = metadata?.relPath ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
return BookChapter(index + 1,startOffset, startOffset + duration, title)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import androidx.media.utils.MediaConstants
|
||||
import com.audiobookshelf.app.media.MediaManager
|
||||
import com.fasterxml.jackson.annotation.*
|
||||
|
||||
// This auto-detects whether it is a Book or Podcast
|
||||
@@ -50,7 +52,8 @@ class Podcast(
|
||||
// Add new episodes
|
||||
audioTracks.forEach { at ->
|
||||
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||
val newEpisode = PodcastEpisode("local_ep_" + at.localFileId,episodes?.size ?: 0 + 1,null,null,at.title,null,null,null,at,at.duration,0, null)
|
||||
val localEpisodeId = "local_ep_" + at.localFileId
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,at.duration,0, null, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +66,8 @@ class Podcast(
|
||||
}
|
||||
@JsonIgnore
|
||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||
val newEpisode = PodcastEpisode("local_" + audioTrack.localFileId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null,audioTrack,audioTrack.duration,0, null)
|
||||
val localEpisodeId = "local_ep_" + audioTrack.localFileId
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,audioTrack.duration,0, null, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
@@ -82,9 +86,17 @@ class Podcast(
|
||||
index++
|
||||
}
|
||||
}
|
||||
|
||||
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Podcast {
|
||||
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
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)
|
||||
val localEpisodeId = "local_ep_" + episode.id
|
||||
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,audioTrack.duration,0, episode.id, localEpisodeId)
|
||||
episodes?.add(newEpisode)
|
||||
|
||||
var index = 1
|
||||
@@ -95,10 +107,14 @@ class Podcast(
|
||||
return newEpisode
|
||||
}
|
||||
|
||||
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Podcast {
|
||||
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
|
||||
fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
|
||||
val sortedEpisodes = episodes?.sortedByDescending { it.publishedAt }
|
||||
val podcastEpisode = sortedEpisodes?.find { episode ->
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemId && it.episodeId == episode.id }
|
||||
progress == null || !progress.isFinished
|
||||
}
|
||||
return podcastEpisode
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,10 +138,13 @@ class Book(
|
||||
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
||||
tracks = audioTracks
|
||||
tracks?.sortBy { it.index }
|
||||
// TODO: Is it necessary to calculate this each time? check if can remove safely
|
||||
|
||||
var totalDuration = 0.0
|
||||
var currentStartOffset = 0.0
|
||||
tracks?.forEach {
|
||||
totalDuration += it.duration
|
||||
it.startOffset = currentStartOffset
|
||||
currentStartOffset += it.duration
|
||||
}
|
||||
duration = totalDuration
|
||||
}
|
||||
@@ -158,7 +177,6 @@ class Book(
|
||||
}
|
||||
duration = totalDuration
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getLocalCopy(): Book {
|
||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
|
||||
@@ -228,21 +246,32 @@ data class PodcastEpisode(
|
||||
var title:String?,
|
||||
var subtitle:String?,
|
||||
var description:String?,
|
||||
var pubDate:String?,
|
||||
var publishedAt:Long?,
|
||||
var audioFile:AudioFile?,
|
||||
var audioTrack:AudioTrack?,
|
||||
var duration:Double?,
|
||||
var size:Long?,
|
||||
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
||||
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
|
||||
var localEpisodeId:String? // For Android Auto server episodes with local copy
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
val coverUri = if(libraryItem is LocalLibraryItem) {
|
||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||
val coverUri = if (libraryItem is LocalLibraryItem) {
|
||||
libraryItem.getCoverUri()
|
||||
} else {
|
||||
(libraryItem as LibraryItem).getCoverUri()
|
||||
}
|
||||
|
||||
val extras = Bundle()
|
||||
|
||||
if (localEpisodeId != null) {
|
||||
extras.putLong(
|
||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||
)
|
||||
}
|
||||
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
@@ -264,22 +293,21 @@ data class PodcastEpisode(
|
||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||
)
|
||||
}
|
||||
// return MediaMetadataCompat.Builder().apply {
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
||||
//
|
||||
// }.build()
|
||||
val libraryItemDescription = libraryItem.getMediaDescription(null)
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
|
||||
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
|
||||
val mediaId = localEpisodeId ?: id
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(title)
|
||||
.setIconUri(coverUri)
|
||||
.setSubtitle(libraryItemDescription.title)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
|
||||
libraryItemDescription.iconBitmap?.let {
|
||||
mediaDescriptionBuilder.setIconBitmap(it)
|
||||
}
|
||||
|
||||
return mediaDescriptionBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,36 +357,6 @@ data class Folder(
|
||||
var fullPath:String
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class AudioTrack(
|
||||
var index:Int,
|
||||
var startOffset:Double,
|
||||
var duration:Double,
|
||||
var title:String,
|
||||
var contentUrl:String,
|
||||
var mimeType:String,
|
||||
var metadata:FileMetadata?,
|
||||
var isLocal:Boolean,
|
||||
var localFileId:String?,
|
||||
var audioProbeResult:AudioProbeResult?,
|
||||
var serverIndex:Int? // Need to know if server track index is different
|
||||
) {
|
||||
|
||||
@get:JsonIgnore
|
||||
val startOffsetMs get() = (startOffset * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val durationMs get() = (duration * 1000L).toLong()
|
||||
@get:JsonIgnore
|
||||
val endOffsetMs get() = startOffsetMs + durationMs
|
||||
@get:JsonIgnore
|
||||
val relPath get() = metadata?.relPath ?: ""
|
||||
|
||||
@JsonIgnore
|
||||
fun getBookChapter():BookChapter {
|
||||
return BookChapter(index + 1,startOffset, startOffset + duration, title)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class BookChapter(
|
||||
var id:Int,
|
||||
@@ -372,26 +370,14 @@ data class BookChapter(
|
||||
val endMs get() = (end * 1000L).toLong()
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class MediaProgress(
|
||||
var id:String,
|
||||
var libraryItemId:String,
|
||||
var episodeId:String?,
|
||||
var duration:Double, // seconds
|
||||
progress:Double, // 0 to 1
|
||||
var currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?
|
||||
) : MediaProgressWrapper(isFinished, progress)
|
||||
|
||||
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
|
||||
@JsonSubTypes(
|
||||
JsonSubTypes.Type(MediaProgress::class),
|
||||
JsonSubTypes.Type(LocalMediaProgress::class)
|
||||
)
|
||||
open class MediaProgressWrapper(var isFinished:Boolean, var progress:Double)
|
||||
open class MediaProgressWrapper(var isFinished:Boolean, var currentTime:Double, var progress:Double) {
|
||||
open val mediaItemId get() = ""
|
||||
}
|
||||
|
||||
// Helper class
|
||||
data class LibraryItemWithEpisode(
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||
|
||||
enum class LockOrientationSetting {
|
||||
NONE, PORTRAIT, LANDSCAPE
|
||||
}
|
||||
|
||||
enum class HapticFeedbackSetting {
|
||||
OFF, LIGHT, MEDIUM, HEAVY
|
||||
}
|
||||
|
||||
enum class ShakeSensitivitySetting {
|
||||
VERY_LOW, LOW, MEDIUM, HIGH, VERY_HIGH
|
||||
}
|
||||
|
||||
data class ServerConnectionConfig(
|
||||
var id:String,
|
||||
var index:Int,
|
||||
@@ -17,46 +31,6 @@ data class ServerConnectionConfig(
|
||||
var customHeaders:Map<String, String>?
|
||||
)
|
||||
|
||||
data class DeviceSettings(
|
||||
var disableAutoRewind:Boolean,
|
||||
var enableAltView:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var disableShakeToResetSleepTimer:Boolean
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
fun default():DeviceSettings {
|
||||
return DeviceSettings(
|
||||
disableAutoRewind = false,
|
||||
enableAltView = false,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
disableShakeToResetSleepTimer = false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val jumpBackwardsTimeMs get() = (jumpBackwardsTime ?: default().jumpBackwardsTime) * 1000L
|
||||
@get:JsonIgnore
|
||||
val jumpForwardTimeMs get() = (jumpForwardTime ?: default().jumpForwardTime) * 1000L
|
||||
}
|
||||
|
||||
data class DeviceData(
|
||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
||||
var lastServerConnectionConfigId:String?,
|
||||
var currentLocalPlaybackSession:PlaybackSession?, // Stored to open up where left off for local media
|
||||
var deviceSettings:DeviceSettings?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getLastServerConnectionConfig():ServerConnectionConfig? {
|
||||
return lastServerConnectionConfigId?.let { lsccid ->
|
||||
return serverConnectionConfigs.find { it.id == lsccid }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class LocalFile(
|
||||
var id:String,
|
||||
@@ -95,7 +69,7 @@ data class LocalFolder(
|
||||
)
|
||||
open class LibraryItemWrapper(var id:String) {
|
||||
@JsonIgnore
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
open fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat { return MediaDescriptionCompat.Builder().build() }
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -114,3 +88,80 @@ data class PlayItemRequestPayload(
|
||||
var forceTranscode:Boolean,
|
||||
var deviceInfo:DeviceInfo
|
||||
)
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class DeviceSettings(
|
||||
var disableAutoRewind:Boolean,
|
||||
var enableAltView:Boolean,
|
||||
var jumpBackwardsTime:Int,
|
||||
var jumpForwardTime:Int,
|
||||
var disableShakeToResetSleepTimer:Boolean,
|
||||
var shakeSensitivity: ShakeSensitivitySetting,
|
||||
var lockOrientation: LockOrientationSetting,
|
||||
var hapticFeedback: HapticFeedbackSetting,
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var sleepTimerLength: Long // Time in milliseconds
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
fun default():DeviceSettings {
|
||||
return DeviceSettings(
|
||||
disableAutoRewind = false,
|
||||
enableAltView = true,
|
||||
jumpBackwardsTime = 10,
|
||||
jumpForwardTime = 10,
|
||||
disableShakeToResetSleepTimer = false,
|
||||
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||
lockOrientation = LockOrientationSetting.NONE,
|
||||
hapticFeedback = HapticFeedbackSetting.LIGHT,
|
||||
autoSleepTimer = false,
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L // 15 minutes
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val jumpBackwardsTimeMs get() = jumpBackwardsTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val jumpForwardTimeMs get() = jumpForwardTime * 1000L
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerStartHour get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerStartMinute get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndHour get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||
@get:JsonIgnore
|
||||
val autoSleepTimerEndMinute get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||
|
||||
@JsonIgnore
|
||||
fun getShakeThresholdGravity() : Float { // Used in ShakeDetector
|
||||
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.2f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.4f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.6f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
|
||||
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
|
||||
else {
|
||||
Log.e("DeviceSetting", "Invalid ShakeSensitivitySetting $shakeSensitivity")
|
||||
1.6f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DeviceData(
|
||||
var serverConnectionConfigs:MutableList<ServerConnectionConfig>,
|
||||
var lastServerConnectionConfigId:String?,
|
||||
var currentLocalPlaybackSession: PlaybackSession?, // Stored to open up where left off for local media
|
||||
var deviceSettings: DeviceSettings?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
|
||||
return lastServerConnectionConfigId?.let { lsccid ->
|
||||
return serverConnectionConfigs.find { it.id == lsccid }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
@@ -29,7 +30,8 @@ class LibraryItem(
|
||||
var mediaType:String,
|
||||
var media:MediaType,
|
||||
var libraryFiles:MutableList<LibraryFile>?,
|
||||
var userMediaProgress:MediaProgress? // Only included when requesting library item with progress (for downloads)
|
||||
var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads)
|
||||
var localLibraryItemId:String? // For Android Auto
|
||||
) : LibraryItemWrapper(id) {
|
||||
@get:JsonIgnore
|
||||
val title get() = media.metadata.title
|
||||
@@ -55,8 +57,16 @@ class LibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||
val extras = Bundle()
|
||||
|
||||
if (localLibraryItemId != null) {
|
||||
extras.putLong(
|
||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||
)
|
||||
}
|
||||
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
@@ -79,8 +89,9 @@ class LibraryItem(
|
||||
)
|
||||
}
|
||||
|
||||
val mediaId = localLibraryItemId ?: id
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setMediaId(mediaId)
|
||||
.setTitle(title)
|
||||
.setIconUri(getCoverUri())
|
||||
.setSubtitle(authorName)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.util.Log
|
||||
import androidx.media.utils.MediaConstants
|
||||
@@ -9,6 +14,7 @@ import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.audiobookshelf.app.player.PLAYMETHOD_LOCAL
|
||||
import java.util.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -96,10 +102,26 @@ class LocalLibraryItem(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
||||
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
|
||||
val coverUri = getCoverUri()
|
||||
|
||||
var bitmap:Bitmap? = null
|
||||
if (coverContentUrl != null) {
|
||||
ctx?.let {
|
||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(it.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(it.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val extras = Bundle()
|
||||
extras.putLong(
|
||||
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||
)
|
||||
if (progress != null) {
|
||||
if (progress.isFinished) {
|
||||
extras.putInt(
|
||||
@@ -122,12 +144,17 @@ class LocalLibraryItem(
|
||||
)
|
||||
}
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setMediaId(id)
|
||||
.setTitle(title)
|
||||
.setIconUri(coverUri)
|
||||
.setSubtitle(authorName)
|
||||
.setExtras(extras)
|
||||
.build()
|
||||
|
||||
bitmap?.let {
|
||||
mediaDescriptionBuilder.setIconBitmap(bitmap)
|
||||
}
|
||||
|
||||
return mediaDescriptionBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import kotlin.math.roundToInt
|
||||
@@ -11,7 +12,7 @@ class LocalMediaProgress(
|
||||
var localEpisodeId:String?,
|
||||
var duration:Double,
|
||||
progress:Double, // 0 to 1
|
||||
var currentTime:Double,
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
@@ -22,9 +23,16 @@ class LocalMediaProgress(
|
||||
var serverUserId:String?,
|
||||
var libraryItemId:String?,
|
||||
var episodeId:String?
|
||||
) : MediaProgressWrapper(isFinished, progress) {
|
||||
) : MediaProgressWrapper(isFinished, currentTime, progress) {
|
||||
@get:JsonIgnore
|
||||
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
|
||||
@get:JsonIgnore
|
||||
override val mediaItemId get() = if (libraryItemId != null) {
|
||||
if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||
} else {
|
||||
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
}
|
||||
|
||||
|
||||
@JsonIgnore
|
||||
fun updateIsFinished(finished:Boolean) {
|
||||
@@ -41,8 +49,7 @@ class LocalMediaProgress(
|
||||
fun updateFromPlaybackSession(playbackSession:PlaybackSession) {
|
||||
currentTime = playbackSession.currentTime
|
||||
progress = playbackSession.progress
|
||||
lastUpdate = System.currentTimeMillis()
|
||||
|
||||
lastUpdate = playbackSession.updatedAt
|
||||
isFinished = playbackSession.progress >= 0.99
|
||||
finishedAt = if (isFinished) lastUpdate else null
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class MediaItemEvent(
|
||||
var name:String, // e.g. Play/Pause/Stop/Seek/Save
|
||||
var type:String, // Playback/Info
|
||||
var description:String?,
|
||||
var currentTime:Number?, // Seconds
|
||||
var serverSyncAttempted:Boolean?,
|
||||
var serverSyncSuccess:Boolean?,
|
||||
var serverSyncMessage:String?,
|
||||
var timestamp: Long
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class MediaItemHistory(
|
||||
var id: String, // media id
|
||||
var mediaDisplayTitle: String,
|
||||
var libraryItemId: String,
|
||||
var episodeId: String?,
|
||||
var isLocal:Boolean,
|
||||
var serverConnectionConfigId:String?,
|
||||
var serverAddress:String?,
|
||||
var serverUserId:String?,
|
||||
var createdAt: Long,
|
||||
var events:MutableList<MediaItemEvent>,
|
||||
)
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class MediaProgress(
|
||||
var id:String,
|
||||
var libraryItemId:String,
|
||||
var episodeId:String?,
|
||||
var duration:Double, // seconds
|
||||
progress:Double, // 0 to 1
|
||||
currentTime:Double,
|
||||
isFinished:Boolean,
|
||||
var lastUpdate:Long,
|
||||
var startedAt:Long,
|
||||
var finishedAt:Long?
|
||||
) : MediaProgressWrapper(isFinished, currentTime, progress) {
|
||||
|
||||
@get:JsonIgnore
|
||||
override val mediaItemId get() = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
@@ -12,12 +15,7 @@ import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.gms.cast.MediaInfo
|
||||
import com.google.android.gms.cast.MediaQueueItem
|
||||
import com.google.android.gms.common.images.WebImage
|
||||
|
||||
// TODO: enum or something in kotlin?
|
||||
val PLAYMETHOD_DIRECTPLAY = 0
|
||||
val PLAYMETHOD_DIRECTSTREAM = 1
|
||||
val PLAYMETHOD_TRANSCODE = 2
|
||||
val PLAYMETHOD_LOCAL = 3
|
||||
import com.audiobookshelf.app.player.*
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
class PlaybackSession(
|
||||
@@ -53,17 +51,21 @@ class PlaybackSession(
|
||||
@get:JsonIgnore
|
||||
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
|
||||
@get:JsonIgnore
|
||||
val isPodcastEpisode get() = mediaType == "podcast"
|
||||
@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"
|
||||
val localMediaProgressId get() = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
@get:JsonIgnore
|
||||
val progress get() = currentTime / getTotalDuration()
|
||||
@get:JsonIgnore
|
||||
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
|
||||
@get:JsonIgnore
|
||||
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackIndex():Int {
|
||||
@@ -76,12 +78,41 @@ class PlaybackSession(
|
||||
return audioTracks.size - 1
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextTrackIndex():Int {
|
||||
for (i in 0 until audioTracks.size) {
|
||||
val track = audioTracks[i]
|
||||
if (currentTimeMs < track.startOffsetMs) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return audioTracks.size - 1
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getChapterForTime(time:Long):BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time >= it.startMs && it.endMs > time}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackEndTime():Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextChapterForTime(time:Long):BookChapter? {
|
||||
if (chapters.isEmpty()) return null
|
||||
return chapters.find { time < it.startMs } // First chapter where start time is > then time
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextTrackEndTime():Long {
|
||||
val currentTrack = audioTracks[this.getNextTrackIndex()]
|
||||
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getCurrentTrackTimeMs():Long {
|
||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||
@@ -105,9 +136,9 @@ class PlaybackSession(
|
||||
|
||||
@JsonIgnore
|
||||
fun getCoverUri(): Uri {
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
if (localLibraryItem?.coverContentUrl != null) return Uri.parse(localLibraryItem?.coverContentUrl) ?: Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
|
||||
|
||||
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
if (coverPath == null) return Uri.parse("android.resource://com.audiobookshelf.app/" + com.audiobookshelf.app.R.drawable.icon)
|
||||
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||
}
|
||||
|
||||
@@ -118,13 +149,33 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getMediaMetadataCompat(): MediaMetadataCompat {
|
||||
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCoverUri().toString())
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCoverUri().toString())
|
||||
|
||||
// Local covers get bitmap
|
||||
if (localLibraryItem?.coverContentUrl != null) {
|
||||
val bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, getCoverUri())
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, getCoverUri())
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap)
|
||||
metadataBuilder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap)
|
||||
}
|
||||
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
@@ -136,6 +187,9 @@ class PlaybackSession(
|
||||
.setArtist(displayAuthor)
|
||||
.setAlbumArtist(displayAuthor)
|
||||
.setSubtitle(displayAuthor)
|
||||
.setAlbumTitle(displayAuthor)
|
||||
.setDescription(displayAuthor)
|
||||
.setArtworkUri(getCoverUri())
|
||||
|
||||
val contentUri = this.getContentUri(audioTrack)
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
@@ -169,7 +223,9 @@ class PlaybackSession(
|
||||
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE, displayAuthor ?: "")
|
||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE, audioTrack.title)
|
||||
|
||||
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
|
||||
return castMetadata
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.audiobookshelf.app.device
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
|
||||
interface WidgetEventEmitter {
|
||||
@@ -11,8 +12,8 @@ interface WidgetEventEmitter {
|
||||
object DeviceManager {
|
||||
const val tag = "DeviceManager"
|
||||
|
||||
val dbManager:DbManager = DbManager()
|
||||
var deviceData:DeviceData = dbManager.getDeviceData()
|
||||
val dbManager: DbManager = DbManager()
|
||||
var deviceData: DeviceData = dbManager.getDeviceData()
|
||||
var serverConnectionConfig: ServerConnectionConfig? = null
|
||||
|
||||
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
||||
@@ -20,12 +21,21 @@ object DeviceManager {
|
||||
val serverUserId get() = serverConnectionConfig?.userId ?: ""
|
||||
val token get() = serverConnectionConfig?.token ?: ""
|
||||
val isConnectedToServer get() = serverConnectionConfig != null
|
||||
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
|
||||
|
||||
var widgetUpdater:WidgetEventEmitter? = null
|
||||
|
||||
init {
|
||||
Log.d(tag, "Device Manager Singleton invoked")
|
||||
|
||||
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
|
||||
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null || deviceData.deviceSettings?.autoSleepTimerEndTime == null) {
|
||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
|
||||
deviceData.deviceSettings?.autoSleepTimerStartTime = "06:00"
|
||||
deviceData.deviceSettings?.sleepTimerLength = 900000L
|
||||
}
|
||||
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
|
||||
}
|
||||
}
|
||||
|
||||
fun getBase64Id(id:String):String {
|
||||
|
||||
@@ -9,7 +9,7 @@ import com.arthenica.ffmpegkit.FFmpegKitConfig
|
||||
import com.arthenica.ffmpegkit.FFprobeKit
|
||||
import com.arthenica.ffmpegkit.Level
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.fasterxml.jackson.module.kotlin.readValue
|
||||
@@ -18,7 +18,7 @@ import org.json.JSONException
|
||||
|
||||
class FolderScanner(var ctx: Context) {
|
||||
private val tag = "FolderScanner"
|
||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class DownloadItemScanResult(val localLibraryItem:LocalLibraryItem, var localMediaProgress:LocalMediaProgress?)
|
||||
|
||||
@@ -72,18 +72,18 @@ class FolderScanner(var ctx: Context) {
|
||||
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
|
||||
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
||||
|
||||
val result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)
|
||||
|
||||
if (result == ItemScanResult.REMOVED) mediaItemsRemoved++
|
||||
else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++
|
||||
else if (result == ItemScanResult.ADDED) mediaItemsAdded++
|
||||
else mediaItemsUpToDate++
|
||||
when (scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)) {
|
||||
ItemScanResult.REMOVED -> mediaItemsRemoved++
|
||||
ItemScanResult.UPDATED -> mediaItemsUpdated++
|
||||
ItemScanResult.ADDED -> mediaItemsAdded++
|
||||
else -> mediaItemsUpToDate++
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
|
||||
|
||||
return if (mediaItemsAdded > 0 || mediaItemsUpdated > 0 || mediaItemsRemoved > 0) {
|
||||
var folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
|
||||
val folderLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id) // Get all local media items
|
||||
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
|
||||
} else {
|
||||
Log.d(tag, "No Media Items to save")
|
||||
@@ -91,7 +91,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
||||
private fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
||||
val itemFolderName = itemFolder.name ?: ""
|
||||
val itemId = getLocalLibraryItemId(itemFolder.id)
|
||||
|
||||
@@ -130,7 +130,7 @@ class FolderScanner(var ctx: Context) {
|
||||
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
||||
|
||||
if (isAudio) {
|
||||
var audioTrackToAdd:AudioTrack? = null
|
||||
val audioTrackToAdd:AudioTrack?
|
||||
|
||||
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
||||
if (existingAudioTrack != null) { // Update existing audio track
|
||||
@@ -219,16 +219,18 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
// Scan item after download and create local library item
|
||||
fun scanDownloadItem(downloadItem: AbsDownloader.DownloadItem):DownloadItemScanResult? {
|
||||
fun scanDownloadItem(downloadItem: DownloadItem, cb: (DownloadItemScanResult?) -> Unit) {
|
||||
val folderDf = DocumentFileCompat.fromUri(ctx, Uri.parse(downloadItem.localFolder.contentUrl))
|
||||
val foldersFound = folderDf?.search(false, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
val foldersFound = folderDf?.search(true, DocumentFileType.FOLDER) ?: mutableListOf()
|
||||
|
||||
var itemFolderId = ""
|
||||
var itemFolderUrl = ""
|
||||
var itemFolderBasePath = ""
|
||||
var itemFolderAbsolutePath = ""
|
||||
foldersFound.forEach {
|
||||
if (it.name == downloadItem.itemTitle) {
|
||||
// e.g. absolute path is "storage/emulated/0/Audiobooks/Orson Scott Card/Enders Game"
|
||||
// and itemSubfolder is "Orson Scott Card/Enders Game"
|
||||
if (it.getAbsolutePath(ctx).endsWith(downloadItem.itemSubfolder)) {
|
||||
itemFolderId = it.id
|
||||
itemFolderUrl = it.uri.toString()
|
||||
itemFolderBasePath = it.getBasePath(ctx)
|
||||
@@ -238,13 +240,13 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
if (itemFolderUrl == "") {
|
||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
||||
return null
|
||||
return cb(null)
|
||||
}
|
||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
||||
return null
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||
@@ -256,7 +258,7 @@ class FolderScanner(var ctx: Context) {
|
||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||
|
||||
var localEpisodeId:String? = null
|
||||
var localLibraryItem:LocalLibraryItem? = null
|
||||
var localLibraryItem:LocalLibraryItem?
|
||||
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)
|
||||
} else {
|
||||
@@ -268,53 +270,50 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
itemPart.filename == docFile.name
|
||||
}
|
||||
if (itemPart == null) {
|
||||
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
|
||||
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
|
||||
val audioTrackFromServer = itemPart.audioTrack
|
||||
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer?.index}")
|
||||
|
||||
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
|
||||
val audioProbeResult = probeAudioFile(localFile.absolutePath)
|
||||
|
||||
// Create new audio track
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioProbeResult?.duration ?: 0.0, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, audioProbeResult, audioTrackFromServer.index)
|
||||
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 ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val 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
|
||||
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
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
filesFound.forEach { docFile ->
|
||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||
itemPart.filename == docFile.name
|
||||
}
|
||||
if (itemPart == null) {
|
||||
if (downloadItem.mediaType == "book") { // for books every download item should be a file found
|
||||
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
|
||||
val audioTrackFromServer = itemPart.audioTrack
|
||||
Log.d(tag, "scanDownloadItem: Audio Track from Server index = ${audioTrackFromServer.index}")
|
||||
|
||||
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)
|
||||
|
||||
// Create new audio track
|
||||
val track = AudioTrack(audioTrackFromServer.index, audioTrackFromServer.startOffset, audioTrackFromServer.duration, localFile.filename ?: "", localFile.contentUrl, localFile.mimeType ?: "", null, true, localFileId, null, audioTrackFromServer.index)
|
||||
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 ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
val 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
|
||||
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
|
||||
localLibraryItem.localFiles.add(localFile)
|
||||
}
|
||||
}
|
||||
|
||||
if (audioTracks.isEmpty()) {
|
||||
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
|
||||
return null
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
// For books sort audio tracks then set
|
||||
@@ -364,7 +363,7 @@ class FolderScanner(var ctx: Context) {
|
||||
|
||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||
|
||||
return downloadItemScanResult
|
||||
cb(downloadItemScanResult)
|
||||
}
|
||||
|
||||
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
||||
@@ -401,7 +400,7 @@ class FolderScanner(var ctx: Context) {
|
||||
localLibraryItem.localFiles.removeIf { it.id == localFileId }
|
||||
|
||||
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
|
||||
Log.d(tag, "scanLocalLibraryItem audio track file ${localFileId} was removed from ${localLibraryItem.absolutePath}")
|
||||
Log.d(tag, "scanLocalLibraryItem audio track file $localFileId was removed from ${localLibraryItem.absolutePath}")
|
||||
localLibraryItem.media.removeAudioTrack(localFileId)
|
||||
}
|
||||
wasUpdated = true
|
||||
@@ -464,7 +463,7 @@ class FolderScanner(var ctx: Context) {
|
||||
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
||||
}
|
||||
|
||||
fun probeAudioFile(absolutePath:String):AudioProbeResult? {
|
||||
private fun probeAudioFile(absolutePath:String):AudioProbeResult? {
|
||||
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
||||
|
||||
var probeObject:JSObject? = null
|
||||
@@ -475,13 +474,13 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
|
||||
Log.d(tag, "FFprobe output $probeObject")
|
||||
if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
|
||||
return if (probeObject == null || !probeObject.has("streams")) { // Check if output is empty
|
||||
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
|
||||
return null
|
||||
null
|
||||
} else {
|
||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
||||
return audioProbeResult
|
||||
audioProbeResult
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+35
-26
@@ -1,8 +1,9 @@
|
||||
package com.audiobookshelf.app.data
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import io.paperdb.Paper
|
||||
import java.io.File
|
||||
|
||||
@@ -10,7 +11,7 @@ class DbManager {
|
||||
val tag = "DbManager"
|
||||
|
||||
companion object {
|
||||
var isDbInitialized = false
|
||||
private var isDbInitialized = false
|
||||
|
||||
fun initialize(ctx: Context) {
|
||||
if (isDbInitialized) return
|
||||
@@ -23,14 +24,14 @@ class DbManager {
|
||||
fun getDeviceData(): DeviceData {
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
|
||||
}
|
||||
fun saveDeviceData(deviceData:DeviceData) {
|
||||
fun saveDeviceData(deviceData: DeviceData) {
|
||||
Paper.book("device").write("data", deviceData)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
|
||||
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
||||
Paper.book("localLibraryItems").allKeys.forEach {
|
||||
val 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)
|
||||
}
|
||||
@@ -45,16 +46,16 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemByLId(libraryItemId:String):LocalLibraryItem? {
|
||||
fun getLocalLibraryItemByLId(libraryItemId:String): LocalLibraryItem? {
|
||||
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
||||
}
|
||||
|
||||
fun getLocalLibraryItem(localLibraryItemId:String):LocalLibraryItem? {
|
||||
fun getLocalLibraryItem(localLibraryItemId:String): LocalLibraryItem? {
|
||||
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
||||
}
|
||||
|
||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String):LibraryItemWithEpisode? {
|
||||
var podcastEpisode:PodcastEpisode? = null
|
||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String): LibraryItemWithEpisode? {
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||
@@ -77,15 +78,15 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocalLibraryItem(localLibraryItem:LocalLibraryItem) {
|
||||
fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
|
||||
Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem)
|
||||
}
|
||||
|
||||
fun saveLocalFolder(localFolder:LocalFolder) {
|
||||
fun saveLocalFolder(localFolder: LocalFolder) {
|
||||
Paper.book("localFolders").write(localFolder.id,localFolder)
|
||||
}
|
||||
|
||||
fun getLocalFolder(folderId:String):LocalFolder? {
|
||||
fun getLocalFolder(folderId:String): LocalFolder? {
|
||||
return Paper.book("localFolders").read(folderId)
|
||||
}
|
||||
|
||||
@@ -107,7 +108,7 @@ class DbManager {
|
||||
Paper.book("localFolders").delete(folderId)
|
||||
}
|
||||
|
||||
fun saveDownloadItem(downloadItem: AbsDownloader.DownloadItem) {
|
||||
fun saveDownloadItem(downloadItem: DownloadItem) {
|
||||
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
||||
}
|
||||
|
||||
@@ -115,21 +116,21 @@ class DbManager {
|
||||
Paper.book("downloadItems").delete(downloadItemId)
|
||||
}
|
||||
|
||||
fun getDownloadItems():List<AbsDownloader.DownloadItem> {
|
||||
val downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
|
||||
fun getDownloadItems():List<DownloadItem> {
|
||||
val downloadItems:MutableList<DownloadItem> = mutableListOf()
|
||||
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
||||
Paper.book("downloadItems").read<AbsDownloader.DownloadItem>(downloadItemId)?.let {
|
||||
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let {
|
||||
downloadItems.add(it)
|
||||
}
|
||||
}
|
||||
return downloadItems
|
||||
}
|
||||
|
||||
fun saveLocalMediaProgress(mediaProgress:LocalMediaProgress) {
|
||||
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
||||
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
|
||||
}
|
||||
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
||||
fun getLocalMediaProgress(localMediaProgressId:String):LocalMediaProgress? {
|
||||
fun getLocalMediaProgress(localMediaProgressId:String): LocalMediaProgress? {
|
||||
return Paper.book("localMediaProgress").read(localMediaProgressId)
|
||||
}
|
||||
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
|
||||
@@ -154,7 +155,7 @@ class DbManager {
|
||||
val localLibraryItems = getLocalLibraryItems()
|
||||
|
||||
localLibraryItems.forEach { lli ->
|
||||
var hasUpates = false
|
||||
var hasUpdates = false
|
||||
|
||||
// Check local files
|
||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
||||
@@ -162,7 +163,7 @@ class DbManager {
|
||||
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
|
||||
hasUpdates = true
|
||||
}
|
||||
file.exists()
|
||||
} as MutableList<LocalFile>
|
||||
@@ -173,7 +174,7 @@ class DbManager {
|
||||
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}")
|
||||
hasUpates = true
|
||||
hasUpdates = true
|
||||
}
|
||||
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||
} as MutableList<PodcastEpisode>
|
||||
@@ -182,7 +183,7 @@ class DbManager {
|
||||
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}")
|
||||
hasUpates = true
|
||||
hasUpdates = true
|
||||
}
|
||||
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||
} as MutableList<AudioTrack>
|
||||
@@ -196,11 +197,11 @@ class DbManager {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
|
||||
lli.coverAbsolutePath = null
|
||||
lli.coverContentUrl = null
|
||||
hasUpates = true
|
||||
hasUpdates = true
|
||||
}
|
||||
}
|
||||
|
||||
if (hasUpates) {
|
||||
if (hasUpdates) {
|
||||
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
||||
Paper.book("localLibraryItems").write(lli.id, lli)
|
||||
}
|
||||
@@ -236,10 +237,18 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocalPlaybackSession(playbackSession:PlaybackSession) {
|
||||
fun saveLocalPlaybackSession(playbackSession: PlaybackSession) {
|
||||
Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession)
|
||||
}
|
||||
fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? {
|
||||
fun getLocalPlaybackSession(playbackSessionId:String): PlaybackSession? {
|
||||
return Paper.book("localPlaybackSession").read(playbackSessionId)
|
||||
}
|
||||
|
||||
|
||||
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
|
||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
|
||||
}
|
||||
fun getMediaItemHistory(id:String): MediaItemHistory? {
|
||||
return Paper.book("mediaItemHistory").read(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.callback.FileCallback
|
||||
import com.anggrayudi.storage.file.DocumentFileCompat
|
||||
import com.anggrayudi.storage.file.MimeType
|
||||
import com.anggrayudi.storage.file.getAbsolutePath
|
||||
import com.anggrayudi.storage.file.moveFileTo
|
||||
import com.anggrayudi.storage.media.FileDescription
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
||||
val tag = "DownloadItemManager"
|
||||
private val maxSimultaneousDownloads = 1
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
enum class DownloadCheckStatus {
|
||||
InProgress,
|
||||
Successful,
|
||||
Failed
|
||||
}
|
||||
|
||||
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf() // All pending and downloading items
|
||||
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf() // Item parts currently being downloaded
|
||||
|
||||
interface DownloadEventEmitter {
|
||||
fun onDownloadItem(downloadItem:DownloadItem)
|
||||
fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart)
|
||||
fun onDownloadItemComplete(jsobj:JSObject)
|
||||
}
|
||||
|
||||
companion object {
|
||||
var isDownloading:Boolean = false
|
||||
}
|
||||
|
||||
fun addDownloadItem(downloadItem:DownloadItem) {
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
Log.i(tag, "Add download item ${downloadItem.media.metadata.title}")
|
||||
|
||||
downloadItemQueue.add(downloadItem)
|
||||
clientEventEmitter.onDownloadItem(downloadItem)
|
||||
checkUpdateDownloadQueue()
|
||||
}
|
||||
|
||||
private fun checkUpdateDownloadQueue() {
|
||||
for (downloadItem in downloadItemQueue) {
|
||||
val numPartsToGet = maxSimultaneousDownloads - currentDownloadItemParts.size
|
||||
val nextDownloadItemParts = downloadItem.getNextDownloadItemParts(numPartsToGet)
|
||||
Log.d(tag, "checkUpdateDownloadQueue: numPartsToGet=$numPartsToGet, nextDownloadItemParts=${nextDownloadItemParts.size}")
|
||||
|
||||
if (nextDownloadItemParts.size > 0) {
|
||||
nextDownloadItemParts.forEach {
|
||||
val dlRequest = it.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
it.downloadId = downloadId
|
||||
Log.d(tag, "checkUpdateDownloadQueue: Starting download item part, downloadId=$downloadId")
|
||||
currentDownloadItemParts.add(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size >= maxSimultaneousDownloads) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (currentDownloadItemParts.size > 0) startWatchingDownloads()
|
||||
}
|
||||
|
||||
private fun startWatchingDownloads() {
|
||||
if (isDownloading) return // Already watching
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
Log.d(tag, "Starting watching downloads")
|
||||
isDownloading = true
|
||||
|
||||
while (currentDownloadItemParts.size > 0) {
|
||||
|
||||
val itemParts = currentDownloadItemParts.filter { !it.isMoving }.map { it }
|
||||
for (downloadItemPart in itemParts) {
|
||||
val downloadCheckStatus = checkDownloadItemPart(downloadItemPart)
|
||||
clientEventEmitter.onDownloadItemPartUpdate(downloadItemPart)
|
||||
|
||||
// Will move to final destination, remove current item parts, and check if download item is finished
|
||||
handleDownloadItemPartCheck(downloadCheckStatus, downloadItemPart)
|
||||
}
|
||||
|
||||
delay(500)
|
||||
|
||||
if (currentDownloadItemParts.size < maxSimultaneousDownloads) {
|
||||
checkUpdateDownloadQueue()
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "Finished watching downloads")
|
||||
isDownloading = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadItemPart(downloadItemPart:DownloadItemPart):DownloadCheckStatus {
|
||||
val downloadId = downloadItemPart.downloadId ?: return DownloadCheckStatus.Failed
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
downloadManager.query(query).use {
|
||||
if (it.moveToFirst()) {
|
||||
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val bytesDownloadedColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
|
||||
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
|
||||
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
|
||||
val bytesDownloadedSoFar = if (bytesDownloadedColumnIndex >= 0) it.getLong(bytesDownloadedColumnIndex) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
|
||||
|
||||
return when (downloadStatus) {
|
||||
DownloadManager.STATUS_SUCCESSFUL -> {
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Successful")
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.progress = 1
|
||||
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
|
||||
|
||||
DownloadCheckStatus.Successful
|
||||
}
|
||||
DownloadManager.STATUS_FAILED -> {
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.failed = true
|
||||
|
||||
DownloadCheckStatus.Failed
|
||||
}
|
||||
else -> {
|
||||
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
|
||||
downloadItemPart.progress = percentProgress
|
||||
downloadItemPart.bytesDownloaded = bytesDownloadedSoFar
|
||||
|
||||
DownloadCheckStatus.InProgress
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
|
||||
downloadItemPart.completed = true
|
||||
downloadItemPart.failed = true
|
||||
return DownloadCheckStatus.Failed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDownloadItemPartCheck(downloadCheckStatus:DownloadCheckStatus, downloadItemPart:DownloadItemPart) {
|
||||
val downloadItem = downloadItemQueue.find { it.id == downloadItemPart.downloadItemId }
|
||||
if (downloadItem == null) {
|
||||
Log.e(tag, "Download item part finished but download item not found ${downloadItemPart.filename}")
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else if (downloadCheckStatus == DownloadCheckStatus.Successful) {
|
||||
|
||||
val file = DocumentFileCompat.fromUri(mainActivity, downloadItemPart.destinationUri)
|
||||
Log.d(tag, "DOWNLOAD: DESTINATION URI ${downloadItemPart.destinationUri}")
|
||||
|
||||
val fcb = object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
override fun onFailed(errorCode: ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
downloadItemPart.isMoving = false
|
||||
file?.delete()
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
override fun onCompleted(result:Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO (name=${resultDocFile.name}) ${resultDocFile.getAbsolutePath(mainActivity)}")
|
||||
|
||||
// Rename to fix appended .mp3 on m4b/m4a files
|
||||
// REF: https://github.com/anggrayudi/SimpleStorage/issues/94
|
||||
if (resultDocFile.name?.endsWith(".m4b.mp3") == true || resultDocFile.name?.endsWith(".m4a.mp3") == true) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
downloadItemPart.moved = true
|
||||
downloadItemPart.isMoving = false
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
val localFolderFile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
// fAILED
|
||||
downloadItemPart.failed = true
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
downloadItemPart.isMoving = true
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.finalDestinationSubfolder, mimetype)
|
||||
file?.moveFileTo(mainActivity, localFolderFile, fileDescription, fcb)
|
||||
}
|
||||
|
||||
} else if (downloadCheckStatus != DownloadCheckStatus.InProgress) {
|
||||
checkDownloadItemFinished(downloadItem)
|
||||
currentDownloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloadItemFinished(downloadItem:DownloadItem) {
|
||||
if (downloadItem.isDownloadFinished) {
|
||||
Log.i(tag, "Download Item finished ${downloadItem.media.metadata.title}")
|
||||
|
||||
folderScanner.scanDownloadItem(downloadItem) { downloadItemScanResult ->
|
||||
Log.d(tag, "Item download complete ${downloadItem.itemTitle} | local library item id: ${downloadItemScanResult?.localLibraryItem?.id}")
|
||||
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("libraryItemId", downloadItem.id)
|
||||
jsobj.put("localFolderId", downloadItem.localFolder.id)
|
||||
|
||||
downloadItemScanResult?.localLibraryItem?.let { localLibraryItem ->
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+116
-33
@@ -1,16 +1,17 @@
|
||||
package com.audiobookshelf.app.player
|
||||
package com.audiobookshelf.app.managers
|
||||
|
||||
import android.content.Context
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
const val SLEEP_EXTENSION_TIME = 900000L // 15m
|
||||
|
||||
class SleepTimerManager constructor(val playerNotificationService:PlayerNotificationService) {
|
||||
class SleepTimerManager constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
|
||||
private var sleepTimerTask:TimerTask? = null
|
||||
@@ -18,7 +19,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
private var sleepTimerEndTime:Long = 0L
|
||||
private var sleepTimerLength:Long = 0L
|
||||
private var sleepTimerElapsed:Long = 0L
|
||||
private var sleepTimerExtensionTime:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
@@ -57,7 +57,7 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerRunning = false
|
||||
sleepTimerRunning = true
|
||||
sleepTimerFinishedAt = 0L
|
||||
sleepTimerElapsed = 0L
|
||||
|
||||
@@ -72,7 +72,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
}
|
||||
sleepTimerEndTime = time
|
||||
sleepTimerLength = 0
|
||||
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
|
||||
|
||||
if (sleepTimerEndTime > getDuration()) {
|
||||
sleepTimerEndTime = getDuration()
|
||||
@@ -80,7 +79,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
} else {
|
||||
sleepTimerLength = time
|
||||
sleepTimerEndTime = 0L
|
||||
sleepTimerExtensionTime = time
|
||||
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
||||
sleepTimerLength = getDuration() - getCurrentTime()
|
||||
@@ -89,9 +87,8 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
|
||||
sleepTimerRunning = true
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
if (getIsPlaying()) {
|
||||
sleepTimerElapsed += 1000L
|
||||
|
||||
@@ -109,9 +106,9 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 30) {
|
||||
} else if (sleepTimeSecondsRemaining <= 60) {
|
||||
// Start fading out audio
|
||||
val volume = sleepTimeSecondsRemaining / 30F
|
||||
val volume = sleepTimeSecondsRemaining / 60F
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
}
|
||||
@@ -121,7 +118,7 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
return true
|
||||
}
|
||||
|
||||
fun clearSleepTimer() {
|
||||
private fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
sleepTimerEndTime = 0
|
||||
@@ -163,21 +160,40 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
}
|
||||
}
|
||||
|
||||
private fun extendSleepTime() {
|
||||
if (!sleepTimerRunning) return
|
||||
setVolume(1F)
|
||||
if (sleepTimerEndTime == 0L) {
|
||||
sleepTimerLength += sleepTimerExtensionTime
|
||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
||||
} else {
|
||||
sleepTimerEndTime += sleepTimerExtensionTime
|
||||
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
|
||||
// Get the chapter end time for use in End of Chapter timers
|
||||
// if less than 2s remain in chapter then use the next chapter
|
||||
private fun getChapterEndTime():Long? {
|
||||
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
|
||||
if (currentChapterEndTimeMs == null) {
|
||||
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
|
||||
return null
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
|
||||
return if (timeLeftInChapter < 2000L) {
|
||||
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 2s remaining")
|
||||
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
|
||||
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
|
||||
Log.e(tag, "Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs")
|
||||
null
|
||||
} else {
|
||||
nextChapterEndTimeMs
|
||||
}
|
||||
} else {
|
||||
currentChapterEndTimeMs
|
||||
}
|
||||
}
|
||||
|
||||
fun checkShouldExtendSleepTimer() {
|
||||
private fun resetChapterTimer() {
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
|
||||
vibrate()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkShouldResetSleepTimer() {
|
||||
if (!sleepTimerRunning) {
|
||||
if (sleepTimerFinishedAt <= 0L) return
|
||||
|
||||
@@ -189,17 +205,35 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
return
|
||||
}
|
||||
|
||||
val newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
|
||||
vibrate()
|
||||
setSleepTimer(newSleepTime, false)
|
||||
play()
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (sleepTimerLength == 0L) {
|
||||
Log.d(tag, "Resetting stopped chapter sleep timer")
|
||||
resetChapterTimer()
|
||||
} else {
|
||||
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
|
||||
vibrate()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
play()
|
||||
}
|
||||
return
|
||||
}
|
||||
// Only extend if within 30 seconds of finishing
|
||||
val sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
if (sleepTimeRemaining <= 30) {
|
||||
|
||||
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
|
||||
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
|
||||
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
|
||||
vibrate()
|
||||
extendSleepTime()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
} else if (sleepTimerLength == 0L) {
|
||||
// When navigating to previous chapters make sure this is still the end of the current chapter
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
if (chapterEndTime != sleepTimerEndTime) {
|
||||
Log.d(tag, "Resetting chapter sleep timer to end of chapter $chapterEndTime from $sleepTimerEndTime")
|
||||
vibrate()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -209,7 +243,7 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||
return
|
||||
}
|
||||
checkShouldExtendSleepTimer()
|
||||
checkShouldResetSleepTimer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -254,4 +288,53 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
}
|
||||
|
||||
fun checkAutoSleepTimer() {
|
||||
if (sleepTimerRunning) { // Sleep timer already running
|
||||
return
|
||||
}
|
||||
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||
if (!deviceSettings.autoSleepTimer) return // Check auto sleep timer is enabled
|
||||
|
||||
val startCalendar = Calendar.getInstance()
|
||||
startCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerStartHour)
|
||||
startCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerStartMinute)
|
||||
val endCalendar = Calendar.getInstance()
|
||||
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
|
||||
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
|
||||
|
||||
// In cases where end time is earlier then start time then we add a day to end time
|
||||
// e.g. start time 22:00 and end time 06:00. End time will be treated as 6am the next day.
|
||||
// e.g. start time 08:00 and end time 22:00. Start and end time will be the same day.
|
||||
if (endCalendar.before(startCalendar)) {
|
||||
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||
}
|
||||
|
||||
val currentCalendar = Calendar.getInstance()
|
||||
val currentHour = SimpleDateFormat("HH:mm", Locale.getDefault()).format(currentCalendar.time)
|
||||
if (currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)) {
|
||||
Log.i(tag, "Current hour $currentHour is between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime} - starting sleep timer")
|
||||
|
||||
// Set sleep timer
|
||||
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||
if (deviceSettings.sleepTimerLength == 0L) {
|
||||
val chapterEndTimeMs = this.getChapterEndTime()
|
||||
if (chapterEndTimeMs == null) {
|
||||
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
|
||||
} else {
|
||||
setSleepTimer(chapterEndTimeMs, true)
|
||||
}
|
||||
} else {
|
||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "Current hour $currentHour is NOT between ${deviceSettings.autoSleepTimerStartTime} and ${deviceSettings.autoSleepTimerEndTime}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun handleMediaPlayEvent() {
|
||||
checkShouldResetSleepTimer()
|
||||
checkAutoSleepTimer()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.audiobookshelf.app.media
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.player.SyncResult
|
||||
|
||||
object MediaEventManager {
|
||||
const val tag = "MediaEventManager"
|
||||
|
||||
var clientEventEmitter: PlayerNotificationService.ClientEventEmitter? = null
|
||||
|
||||
fun playEvent(playbackSession: PlaybackSession) {
|
||||
Log.i(tag, "Play Event for media \"${playbackSession.displayTitle}\"")
|
||||
addPlaybackEvent("Play", playbackSession, null)
|
||||
}
|
||||
|
||||
fun pauseEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Pause Event for media \"${playbackSession.displayTitle}\"")
|
||||
addPlaybackEvent("Pause", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun stopEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Stop Event for media \"${playbackSession.displayTitle}\"")
|
||||
addPlaybackEvent("Stop", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun saveEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Save Event for media \"${playbackSession.displayTitle}\"")
|
||||
addPlaybackEvent("Save", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun finishedEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Finished Event for media \"${playbackSession.displayTitle}\"")
|
||||
addPlaybackEvent("Finished", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||
Log.i(tag, "Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}")
|
||||
addPlaybackEvent("Seek", playbackSession, syncResult)
|
||||
}
|
||||
|
||||
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
|
||||
Log.i(tag, "Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}")
|
||||
addSyncEvent("Sync", mediaProgress, description)
|
||||
}
|
||||
|
||||
private fun addSyncEvent(eventName:String, mediaProgress:MediaProgressWrapper, description: String) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
|
||||
if (mediaItemHistory == null) {
|
||||
Log.w(tag, "addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}")
|
||||
return
|
||||
}
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Sync",
|
||||
description = description,
|
||||
currentTime = mediaProgress.currentTime,
|
||||
serverSyncAttempted = false,
|
||||
serverSyncSuccess = null,
|
||||
serverSyncMessage = null,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
mediaItemHistory.events.add(mediaItemEvent)
|
||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult:SyncResult?) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
name = eventName,
|
||||
type = "Playback",
|
||||
description = "",
|
||||
currentTime = playbackSession.currentTime,
|
||||
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
||||
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
||||
serverSyncMessage = syncResult?.serverSyncMessage,
|
||||
timestamp = System.currentTimeMillis()
|
||||
)
|
||||
mediaItemHistory.events.add(mediaItemEvent)
|
||||
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun getMediaItemHistoryMediaItem(mediaItemId: String) : MediaItemHistory? {
|
||||
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
|
||||
}
|
||||
|
||||
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession):MediaItemHistory {
|
||||
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
|
||||
val isLocalOnly = playbackSession.isLocalLibraryItemOnly
|
||||
val libraryItemId = if (isLocalOnly) playbackSession.localLibraryItemId else playbackSession.libraryItemId ?: ""
|
||||
val episodeId:String? = if (isLocalOnly && playbackSession.localEpisodeId != null) playbackSession.localEpisodeId else playbackSession.episodeId
|
||||
return MediaItemHistory(
|
||||
id = playbackSession.mediaItemId,
|
||||
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
isLocalOnly,
|
||||
playbackSession.
|
||||
serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
events = mutableListOf())
|
||||
}
|
||||
}
|
||||
@@ -15,24 +15,24 @@ import org.json.JSONException
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
val tag = "MediaManager"
|
||||
|
||||
var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
|
||||
var selectedLibraryItems = mutableListOf<LibraryItem>()
|
||||
var selectedLibraryId = ""
|
||||
private var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
|
||||
private var selectedLibraryItems = mutableListOf<LibraryItem>()
|
||||
private var selectedLibraryId = ""
|
||||
|
||||
var selectedPodcast:Podcast? = null
|
||||
var selectedLibraryItemId:String? = null
|
||||
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
||||
var serverLibraryCategories = listOf<LibraryCategory>()
|
||||
private var selectedPodcast:Podcast? = null
|
||||
private var selectedLibraryItemId:String? = null
|
||||
private var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
||||
private var serverLibraryCategories = listOf<LibraryCategory>()
|
||||
private var serverConfigIdUsed:String? = null
|
||||
private var serverConfigLastPing:Long = 0L
|
||||
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||
var serverItemsInProgress = listOf<ItemInProgress>()
|
||||
var serverLibraries = listOf<Library>()
|
||||
var serverConfigIdUsed:String? = null
|
||||
var serverConfigLastPing:Long = 0L
|
||||
var serverUserMediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||
|
||||
var userSettingsPlaybackRate:Float? = null
|
||||
private var userSettingsPlaybackRate:Float? = null
|
||||
|
||||
fun getIsLibrary(id:String) : Boolean {
|
||||
return serverLibraries.find { it.id == id } != null
|
||||
@@ -76,7 +76,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
|
||||
private fun loadItemsInProgressForAllLibraries(cb: (List<ItemInProgress>) -> Unit) {
|
||||
if (serverItemsInProgress.isNotEmpty()) {
|
||||
cb(serverItemsInProgress)
|
||||
} else {
|
||||
@@ -109,7 +109,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
|
||||
private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) {
|
||||
if (libraryItemId.startsWith("local")) {
|
||||
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
|
||||
} else {
|
||||
@@ -121,7 +121,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
|
||||
fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, ctx:Context, cb: (MutableList<MediaBrowserCompat.MediaItem>) -> Unit) {
|
||||
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
||||
Log.d(tag, "Loaded Podcast library item $libraryItemWrapper")
|
||||
|
||||
@@ -138,7 +138,8 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
||||
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}")
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
|
||||
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
@@ -157,7 +158,15 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
val children = podcast.episodes?.map { podcastEpisode ->
|
||||
|
||||
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress)
|
||||
|
||||
// to show download icon
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemWrapper.id)
|
||||
localLibraryItem?.let { lli ->
|
||||
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == podcastEpisode.id }
|
||||
podcastEpisode.localEpisodeId = localEpisode?.id
|
||||
}
|
||||
|
||||
val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
||||
@@ -167,7 +176,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun loadLibraries(cb: (List<Library>) -> Unit) {
|
||||
private fun loadLibraries(cb: (List<Library>) -> Unit) {
|
||||
if (serverLibraries.isNotEmpty()) {
|
||||
cb(serverLibraries)
|
||||
} else {
|
||||
@@ -178,9 +187,9 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
|
||||
private suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean {
|
||||
var successfulPing = false
|
||||
suspendCoroutine<Boolean> { cont ->
|
||||
suspendCoroutine { cont ->
|
||||
apiHandler.pingServer(config) {
|
||||
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
|
||||
successfulPing = it
|
||||
@@ -190,9 +199,9 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
return successfulPing
|
||||
}
|
||||
|
||||
suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
|
||||
private suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
|
||||
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||
suspendCoroutine<MutableList<MediaProgress>> { cont ->
|
||||
suspendCoroutine { cont ->
|
||||
apiHandler.authorize(config) {
|
||||
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
|
||||
if (!it.isNullOrEmpty()) {
|
||||
@@ -204,7 +213,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
return mediaProgress
|
||||
}
|
||||
|
||||
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||
|
||||
coroutineScope {
|
||||
@@ -266,6 +275,23 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
|
||||
}
|
||||
|
||||
fun loadServerUserMediaProgress(cb: () -> Unit) {
|
||||
Log.d(tag, "Loading server media progress")
|
||||
if (DeviceManager.serverConnectionConfig == null) {
|
||||
return cb()
|
||||
}
|
||||
|
||||
DeviceManager.serverConnectionConfig?.let { config ->
|
||||
apiHandler.authorize(config) {
|
||||
Log.d(tag, "loadServerUserMediaProgress: Authorized server config ${config.address} result = $it")
|
||||
if (!it.isNullOrEmpty()) {
|
||||
serverUserMediaProgress = it
|
||||
}
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||
Log.d(tag, "Load android auto items")
|
||||
|
||||
@@ -314,18 +340,18 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
||||
}
|
||||
|
||||
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
|
||||
if (id.startsWith("local")) {
|
||||
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
||||
return if (id.startsWith("local")) {
|
||||
DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
||||
} else {
|
||||
return podcastEpisodeLibraryItemMap[id]
|
||||
podcastEpisodeLibraryItemMap[id]
|
||||
}
|
||||
}
|
||||
|
||||
fun getById(id:String) : LibraryItemWrapper? {
|
||||
if (id.startsWith("local")) {
|
||||
return DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
return if (id.startsWith("local")) {
|
||||
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||
} else {
|
||||
return serverLibraryItems.find { it.id == id }
|
||||
serverLibraryItems.find { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+106
-25
@@ -6,7 +6,9 @@ import android.util.Log
|
||||
import com.audiobookshelf.app.data.LocalMediaProgress
|
||||
import com.audiobookshelf.app.data.MediaProgress
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.data.Podcast
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
@@ -17,6 +19,12 @@ data class MediaProgressSyncData(
|
||||
var currentTime:Double // seconds
|
||||
)
|
||||
|
||||
data class SyncResult(
|
||||
var serverSyncAttempted:Boolean,
|
||||
var serverSyncSuccess:Boolean?,
|
||||
var serverSyncMessage:String?
|
||||
)
|
||||
|
||||
class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||
private val tag = "MediaProgressSync"
|
||||
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||
@@ -30,15 +38,15 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||
|
||||
val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
|
||||
fun start() {
|
||||
fun start(playbackSession:PlaybackSession) {
|
||||
if (listeningTimerRunning) {
|
||||
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
||||
if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
|
||||
if (playbackSession.id != currentSessionId) {
|
||||
Log.d(tag, "Playback session changed, reset timer")
|
||||
currentLocalMediaProgress = null
|
||||
listeningTimerTask?.cancel()
|
||||
@@ -48,25 +56,32 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
} else {
|
||||
return
|
||||
}
|
||||
} else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
|
||||
} else if (playbackSession.id != currentSessionId) {
|
||||
currentLocalMediaProgress = null
|
||||
}
|
||||
|
||||
listeningTimerRunning = true
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
Log.d(tag, "start: init last sync time $lastSyncTime")
|
||||
currentPlaybackSession = playerNotificationService.getCurrentPlaybackSessionCopy()
|
||||
currentPlaybackSession = playbackSession.clone()
|
||||
|
||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||
// Only sync with server on unmetered connection every 5s OR sync with server if last sync time is >= 60s
|
||||
// Set auto sleep timer if enabled and within start/end time
|
||||
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
||||
|
||||
// Only sync with server on unmetered connection every 15s OR sync with server if last sync time is >= 60s
|
||||
val shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
if (currentTime > 0) {
|
||||
sync(shouldSyncServer, currentTime) {
|
||||
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||
Log.d(tag, "Sync complete")
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.saveEvent(playbackSession, syncResult)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,8 +89,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
}
|
||||
|
||||
fun play(playbackSession:PlaybackSession) {
|
||||
Log.d(tag, "play ${playbackSession.displayTitle}")
|
||||
MediaEventManager.playEvent(playbackSession)
|
||||
|
||||
start(playbackSession)
|
||||
}
|
||||
|
||||
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) return
|
||||
if (!listeningTimerRunning) {
|
||||
reset()
|
||||
return cb()
|
||||
}
|
||||
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
@@ -83,11 +109,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
|
||||
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||
if (currentTime > 0) { // Current time should always be > 0 on stop
|
||||
sync(true, currentTime) {
|
||||
sync(true, currentTime) { syncResult ->
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.stopEvent(playbackSession, syncResult)
|
||||
}
|
||||
|
||||
reset()
|
||||
cb()
|
||||
}
|
||||
} else {
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.stopEvent(playbackSession, null)
|
||||
}
|
||||
|
||||
reset()
|
||||
cb()
|
||||
}
|
||||
@@ -95,6 +129,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
|
||||
fun pause(cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) return
|
||||
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
@@ -103,39 +138,82 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
if (currentTime > 0) { // Current time should always be > 0 on pause
|
||||
sync(true, currentTime) {
|
||||
sync(true, currentTime) { syncResult ->
|
||||
lastSyncTime = 0L
|
||||
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
|
||||
failedSyncs = 0
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.pauseEvent(playbackSession, syncResult)
|
||||
}
|
||||
|
||||
cb()
|
||||
}
|
||||
} else {
|
||||
lastSyncTime = 0L
|
||||
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
|
||||
failedSyncs = 0
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.pauseEvent(playbackSession, null)
|
||||
}
|
||||
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
fun finished(cb: () -> Unit) {
|
||||
if (!listeningTimerRunning) return
|
||||
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
Log.d(tag, "finished: Stopping listening for $currentDisplayTitle")
|
||||
|
||||
sync(true, currentPlaybackSession?.duration ?: 0.0) { syncResult ->
|
||||
reset()
|
||||
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
MediaEventManager.finishedEvent(playbackSession, syncResult)
|
||||
}
|
||||
|
||||
cb()
|
||||
}
|
||||
}
|
||||
|
||||
fun seek() {
|
||||
currentPlaybackSession?.currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||
Log.d(tag, "seek: $currentDisplayTitle, currentTime=${currentPlaybackSession?.currentTime}")
|
||||
|
||||
if (currentPlaybackSession == null) {
|
||||
Log.e(tag, "seek: Playback session not set")
|
||||
return
|
||||
}
|
||||
|
||||
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
|
||||
}
|
||||
|
||||
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||
currentPlaybackSession?.let {
|
||||
it.updatedAt = mediaProgress.lastUpdate
|
||||
it.currentTime = mediaProgress.currentTime
|
||||
|
||||
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
|
||||
|
||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||
saveLocalProgress(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) {
|
||||
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
|
||||
if (lastSyncTime <= 0) {
|
||||
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
||||
return
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
||||
if (diffSinceLastSync < 1000L) {
|
||||
return cb()
|
||||
return cb(null)
|
||||
}
|
||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||
|
||||
@@ -145,7 +223,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
|
||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
|
||||
return cb()
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
if (currentIsLocal) {
|
||||
@@ -158,11 +236,12 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
// Local library item is linked to a server library item
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
apiHandler.sendLocalProgressSync(it) { syncSuccess ->
|
||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||
Log.d(
|
||||
tag,
|
||||
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
||||
)
|
||||
|
||||
if (syncSuccess) {
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
@@ -175,15 +254,16 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
|
||||
}
|
||||
|
||||
cb()
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
} else {
|
||||
cb()
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
} else if (shouldSyncServer) {
|
||||
apiHandler.sendProgressSync(currentSessionId, syncData) {
|
||||
if (it) {
|
||||
Log.d(tag, "sync: currentSessionId=$currentSessionId")
|
||||
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||
if (syncSuccess) {
|
||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
@@ -196,10 +276,10 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
|
||||
}
|
||||
cb()
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
} else {
|
||||
cb()
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,17 +290,18 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
||||
} else {
|
||||
currentLocalMediaProgress = mediaProgress
|
||||
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
||||
}
|
||||
} else {
|
||||
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
||||
}
|
||||
|
||||
currentLocalMediaProgress?.let {
|
||||
if (it.progress.isNaN()) {
|
||||
Log.e(tag, "Invalid progress on local media progress")
|
||||
} else {
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
||||
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||
|
||||
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.audiobookshelf.app.models
|
||||
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.MediaProgress
|
||||
import com.audiobookshelf.app.data.MediaType
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
|
||||
data class DownloadItem(
|
||||
val id: String,
|
||||
val libraryItemId:String,
|
||||
val episodeId:String?,
|
||||
val userMediaProgress: MediaProgress?,
|
||||
val serverConnectionConfigId:String,
|
||||
val serverAddress:String,
|
||||
val serverUserId:String,
|
||||
val mediaType: String,
|
||||
val itemFolderPath:String,
|
||||
val localFolder: LocalFolder,
|
||||
val itemTitle: String,
|
||||
val itemSubfolder: String,
|
||||
val media: MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
) {
|
||||
@get:JsonIgnore
|
||||
val isDownloadFinished get() = !downloadItemParts.any { !it.completed || it.isMoving }
|
||||
|
||||
@JsonIgnore
|
||||
fun getNextDownloadItemParts(limit:Int): MutableList<DownloadItemPart> {
|
||||
val itemParts = mutableListOf<DownloadItemPart>()
|
||||
if (limit == 0) return itemParts
|
||||
|
||||
for (it in downloadItemParts) {
|
||||
if (!it.completed && it.downloadId == null) {
|
||||
itemParts.add(it)
|
||||
if (itemParts.size > limit) break
|
||||
}
|
||||
}
|
||||
|
||||
return itemParts
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package com.audiobookshelf.app.models
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.AudioTrack
|
||||
import com.audiobookshelf.app.data.LocalFolder
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import java.io.File
|
||||
|
||||
data class DownloadItemPart(
|
||||
val id: String,
|
||||
val downloadItemId: String,
|
||||
val filename: String,
|
||||
val fileSize: Long,
|
||||
val finalDestinationPath:String,
|
||||
val serverPath: String,
|
||||
val localFolderName: String,
|
||||
val localFolderUrl: String,
|
||||
val localFolderId: String,
|
||||
val audioTrack: AudioTrack?,
|
||||
val episode: PodcastEpisode?,
|
||||
var completed:Boolean,
|
||||
var moved:Boolean,
|
||||
var isMoving:Boolean,
|
||||
var failed:Boolean,
|
||||
@JsonIgnore val uri: Uri,
|
||||
@JsonIgnore val destinationUri: Uri,
|
||||
@JsonIgnore val finalDestinationUri: Uri,
|
||||
val finalDestinationSubfolder: String,
|
||||
var downloadId: Long?,
|
||||
var progress: Long,
|
||||
var bytesDownloaded: Long
|
||||
) {
|
||||
companion object {
|
||||
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||
val destinationUri = Uri.fromFile(destinationFile)
|
||||
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||
|
||||
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),
|
||||
downloadItemId,
|
||||
filename = filename,
|
||||
fileSize = fileSize,
|
||||
finalDestinationPath = finalDestinationFile.absolutePath,
|
||||
serverPath = serverPath,
|
||||
localFolderName = localFolder.name,
|
||||
localFolderUrl = localFolder.contentUrl,
|
||||
localFolderId = localFolder.id,
|
||||
audioTrack = audioTrack,
|
||||
episode = episode,
|
||||
completed = false,
|
||||
moved = false,
|
||||
isMoving = false,
|
||||
failed = false,
|
||||
uri = downloadUri,
|
||||
destinationUri = destinationUri,
|
||||
finalDestinationUri = finalDestinationUri,
|
||||
finalDestinationSubfolder = subfolder,
|
||||
downloadId = null,
|
||||
progress = 0,
|
||||
bytesDownloaded = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
val dlRequest = DownloadManager.Request(uri)
|
||||
dlRequest.setTitle(filename)
|
||||
dlRequest.setDescription("Downloading to $localFolderName with filename $filename")
|
||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
dlRequest.setDestinationUri(destinationUri)
|
||||
return dlRequest
|
||||
}
|
||||
}
|
||||
+24
-10
@@ -2,24 +2,25 @@ package com.audiobookshelf.app.player
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.ImageDecoder
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.R
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
||||
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
private val tag = "MediaDescriptionAdapter"
|
||||
|
||||
var currentIconUri: Uri? = null
|
||||
var currentBitmap: Bitmap? = null
|
||||
private var currentIconUri: Uri? = null
|
||||
private var currentBitmap: Bitmap? = null
|
||||
|
||||
private val serviceJob = SupervisorJob()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
||||
@@ -42,13 +43,26 @@ class AbMediaDescriptionAdapter constructor(private val controller: MediaControl
|
||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||
currentIconUri = albumArtUri
|
||||
Log.d(tag, "ART $currentIconUri")
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
|
||||
if (currentIconUri.toString().startsWith("content://")) {
|
||||
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
@Suppress("DEPRECATION")
|
||||
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
currentBitmap
|
||||
} else {
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
}
|
||||
null
|
||||
}
|
||||
null
|
||||
|
||||
} else {
|
||||
currentBitmap
|
||||
}
|
||||
|
||||
@@ -17,11 +17,10 @@ class BrowseTree(
|
||||
|
||||
/**
|
||||
* get uri to drawable or any other resource type if u wish
|
||||
* @param context - context
|
||||
* @param drawableId - drawable res id
|
||||
* @return - uri
|
||||
*/
|
||||
fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
|
||||
private fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
|
||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||
@@ -49,7 +48,7 @@ class BrowseTree(
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
|
||||
}.build()
|
||||
|
||||
if (!itemsInProgress.isEmpty()) {
|
||||
if (itemsInProgress.isNotEmpty()) {
|
||||
rootList += continueListeningMetadata
|
||||
}
|
||||
|
||||
|
||||
@@ -2,10 +2,7 @@ package com.audiobookshelf.app.player
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.os.Message
|
||||
import android.os.*
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
@@ -18,7 +15,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
var tag = "MediaSessionCallback"
|
||||
|
||||
private var mediaButtonClickCount: Int = 0
|
||||
var mediaButtonClickTimeout: Long = 1000 //ms
|
||||
private var mediaButtonClickTimeout: Long = 1000 //ms
|
||||
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
||||
@@ -28,7 +25,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it,true, playbackRate)
|
||||
}
|
||||
}
|
||||
@@ -54,7 +51,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it, true, playbackRate)
|
||||
}
|
||||
}
|
||||
@@ -93,7 +90,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
|
||||
var libraryItemWrapper: LibraryItemWrapper? = null
|
||||
val libraryItemWrapper: LibraryItemWrapper?
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
|
||||
if (mediaId.isNullOrEmpty()) {
|
||||
@@ -116,7 +113,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it, true, playbackRate)
|
||||
}
|
||||
}
|
||||
@@ -128,11 +125,17 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
return handleCallMediaButton(mediaButtonEvent)
|
||||
}
|
||||
|
||||
fun handleCallMediaButton(intent: Intent): Boolean {
|
||||
private fun handleCallMediaButton(intent: Intent): Boolean {
|
||||
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
|
||||
|
||||
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
|
||||
val keyEvent = intent.getParcelableExtra<KeyEvent>(Intent.EXTRA_KEY_EVENT)
|
||||
val keyEvent = if (Build.VERSION.SDK_INT >= 33) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)
|
||||
}
|
||||
|
||||
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
|
||||
|
||||
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
|
||||
@@ -166,7 +169,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
Log.d(tag, "handleCallMediaButton: Media Play")
|
||||
if (0 == mediaButtonClickCount) {
|
||||
playerNotificationService.play()
|
||||
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
@@ -181,6 +183,12 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||
playerNotificationService.jumpForward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||
playerNotificationService.jumpBackward()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
}
|
||||
@@ -192,7 +200,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
} else {
|
||||
if (0 == mediaButtonClickCount) {
|
||||
playerNotificationService.play()
|
||||
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
|
||||
+4
-4
@@ -35,7 +35,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
||||
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
|
||||
|
||||
var libraryItemWrapper: LibraryItemWrapper? = null
|
||||
val libraryItemWrapper: LibraryItemWrapper?
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
|
||||
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
||||
@@ -63,7 +63,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,3 +4,17 @@ const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_for
|
||||
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
|
||||
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
|
||||
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
|
||||
|
||||
const val PLAYMETHOD_DIRECTPLAY = 0
|
||||
const val PLAYMETHOD_DIRECTSTREAM = 1
|
||||
const val PLAYMETHOD_TRANSCODE = 2
|
||||
const val PLAYMETHOD_LOCAL = 3
|
||||
|
||||
//
|
||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
||||
//
|
||||
const val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead"
|
||||
const val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
|
||||
const val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
|
||||
const val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
|
||||
const val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.data.PlayerState
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.google.android.exoplayer2.PlaybackException
|
||||
@@ -13,16 +14,101 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
|
||||
companion object {
|
||||
var lastPauseTime: Long = 0 //ms
|
||||
var lazyIsPlaying: Boolean = false
|
||||
}
|
||||
|
||||
private var onSeekBack: Boolean = false
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
val errorMessage = error.message ?: "Unknown Error"
|
||||
Log.e(tag, "onPlayerError $errorMessage")
|
||||
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
|
||||
}
|
||||
|
||||
override fun onPositionDiscontinuity(
|
||||
oldPosition: Player.PositionInfo,
|
||||
newPosition: Player.PositionInfo,
|
||||
reason: Int
|
||||
) {
|
||||
if (reason == Player.DISCONTINUITY_REASON_SEEK) {
|
||||
// If playing set seeking flag
|
||||
Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying} reason=SEEK")
|
||||
playerNotificationService.mediaProgressSyncer.seek()
|
||||
lastPauseTime = 0 // When seeking while paused reset the auto-rewind
|
||||
} else {
|
||||
Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying}, reason=$reason")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
Log.d(tag, "onIsPlayingChanged to $isPlaying | ${playerNotificationService.getMediaPlayer()} | playbackState=${playerNotificationService.currentPlayer.playbackState}")
|
||||
|
||||
val player = playerNotificationService.currentPlayer
|
||||
|
||||
// Goal of these 2 if statements and the lazyIsPlaying is to ignore this event when it is triggered by a seek
|
||||
// When a seek occurs the player is paused and buffering, then plays again right afterwards.
|
||||
if (!isPlaying && player.playbackState == Player.STATE_BUFFERING) {
|
||||
Log.d(tag, "onIsPlayingChanged: Pause event when buffering is ignored")
|
||||
return
|
||||
}
|
||||
if (lazyIsPlaying == isPlaying) {
|
||||
Log.d(tag, "onIsPlayingChanged: Lazy is playing $lazyIsPlaying is already set to this so ignoring")
|
||||
return
|
||||
}
|
||||
|
||||
lazyIsPlaying = isPlaying
|
||||
|
||||
if (isPlaying) {
|
||||
Log.d(tag, "SeekBackTime: Player is playing")
|
||||
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
|
||||
var seekBackTime = calcPauseSeekBackTime()
|
||||
if (seekBackTime > 0) {
|
||||
// Current chapter is used so that seek back does not go back to the previous chapter
|
||||
val currentChapter = playerNotificationService.getCurrentBookChapter()
|
||||
val minSeekBackTime = currentChapter?.startMs ?: 0
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTime()
|
||||
val newTime = currentTime - seekBackTime
|
||||
if (newTime < minSeekBackTime) {
|
||||
seekBackTime = currentTime - minSeekBackTime
|
||||
}
|
||||
Log.d(tag, "SeekBackTime $seekBackTime")
|
||||
}
|
||||
|
||||
// Check if playback session still exists or sync media progress if updated
|
||||
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
||||
if (!shouldCarryOn) return
|
||||
}
|
||||
|
||||
if (seekBackTime > 0L) {
|
||||
playerNotificationService.seekBackward(seekBackTime)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "SeekBackTime: Player not playing set last pause time | playbackState=${player.playbackState}")
|
||||
lastPauseTime = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
// Start/stop progress sync interval
|
||||
if (isPlaying) {
|
||||
// Handles auto-starting sleep timer and resetting sleep timer
|
||||
playerNotificationService.sleepTimerManager.handleMediaPlayEvent()
|
||||
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession
|
||||
playbackSession?.let { playerNotificationService.mediaProgressSyncer.play(it) }
|
||||
} else {
|
||||
playerNotificationService.mediaProgressSyncer.pause {
|
||||
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||
}
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying)
|
||||
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||
}
|
||||
|
||||
override fun onEvents(player: Player, events: Player.Events) {
|
||||
Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
|
||||
|
||||
@@ -41,7 +127,7 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration)
|
||||
|
||||
if (lastPauseTime == 0L) {
|
||||
lastPauseTime = -1;
|
||||
lastPauseTime = -1
|
||||
}
|
||||
playerNotificationService.sendClientMetadata(PlayerState.READY)
|
||||
}
|
||||
@@ -52,6 +138,8 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
|
||||
Log.d(tag, "STATE_ENDED")
|
||||
playerNotificationService.sendClientMetadata(PlayerState.ENDED)
|
||||
|
||||
playerNotificationService.handlePlaybackEnded()
|
||||
}
|
||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
|
||||
Log.d(tag, "STATE_IDLE")
|
||||
@@ -65,62 +153,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
||||
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
|
||||
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}")
|
||||
}
|
||||
if (events.contains(Player.EVENT_IS_PLAYING_CHANGED)) {
|
||||
Log.d(tag, "EVENT IS PLAYING CHANGED ${playerNotificationService.getMediaPlayer()}")
|
||||
|
||||
if (player.isPlaying) {
|
||||
Log.d(tag, "SeekBackTime: Player is playing")
|
||||
if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) {
|
||||
var seekBackTime = 0L
|
||||
if (onSeekBack) onSeekBack = false
|
||||
else {
|
||||
Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime")
|
||||
seekBackTime = calcPauseSeekBackTime()
|
||||
if (seekBackTime > 0) {
|
||||
// Current chapter is used so that seek back does not go back to the previous chapter
|
||||
val currentChapter = playerNotificationService.getCurrentBookChapter()
|
||||
val minSeekBackTime = currentChapter?.startMs ?: 0
|
||||
|
||||
val currentTime = playerNotificationService.getCurrentTime()
|
||||
val newTime = currentTime - seekBackTime
|
||||
if (newTime < minSeekBackTime) {
|
||||
seekBackTime = currentTime - minSeekBackTime
|
||||
}
|
||||
Log.d(tag, "SeekBackTime $seekBackTime")
|
||||
onSeekBack = true
|
||||
}
|
||||
}
|
||||
|
||||
// Check if playback session still exists or sync media progress if updated
|
||||
val pauseLength: Long = System.currentTimeMillis() - lastPauseTime
|
||||
if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) {
|
||||
val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime)
|
||||
if (!shouldCarryOn) return
|
||||
}
|
||||
|
||||
if (seekBackTime > 0L) {
|
||||
playerNotificationService.seekBackward(seekBackTime)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "SeekBackTime: Player not playing set last pause time")
|
||||
lastPauseTime = System.currentTimeMillis()
|
||||
}
|
||||
|
||||
// Start/stop progress sync interval
|
||||
if (player.isPlaying) {
|
||||
player.volume = 1F // Volume on sleep timer might have decreased this
|
||||
playerNotificationService.mediaProgressSyncer.start()
|
||||
} else {
|
||||
playerNotificationService.mediaProgressSyncer.pause {
|
||||
Log.d(tag, "Media Progress Syncer paused and synced")
|
||||
}
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onPlayingUpdate(player.isPlaying)
|
||||
|
||||
DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService)
|
||||
}
|
||||
}
|
||||
|
||||
private fun calcPauseSeekBackTime() : Long {
|
||||
|
||||
+132
-42
@@ -3,7 +3,9 @@ package com.audiobookshelf.app.player
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import android.graphics.ImageDecoder
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorManager
|
||||
import android.net.ConnectivityManager
|
||||
@@ -11,8 +13,10 @@ import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.*
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.support.v4.media.session.MediaSessionCompat
|
||||
import android.support.v4.media.session.PlaybackStateCompat
|
||||
@@ -27,9 +31,10 @@ import com.audiobookshelf.app.R
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.data.DeviceInfo
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
import com.audiobookshelf.app.managers.SleepTimerManager
|
||||
import com.audiobookshelf.app.media.MediaManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||
@@ -45,8 +50,8 @@ import kotlin.concurrent.schedule
|
||||
|
||||
|
||||
const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||
const val PLAYER_CAST = "cast-player";
|
||||
const val PLAYER_EXO = "exo-player";
|
||||
const val PLAYER_CAST = "cast-player"
|
||||
const val PLAYER_EXO = "exo-player"
|
||||
|
||||
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
@@ -57,6 +62,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||
}
|
||||
|
||||
private val tag = "PlayerNotificationService"
|
||||
|
||||
interface ClientEventEmitter {
|
||||
fun onPlaybackSession(playbackSession:PlaybackSession)
|
||||
fun onPlaybackClosed()
|
||||
@@ -70,9 +77,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
fun onProgressSyncFailing()
|
||||
fun onProgressSyncSuccess()
|
||||
fun onNetworkMeteredChanged(isUnmetered:Boolean)
|
||||
fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory)
|
||||
}
|
||||
|
||||
private val tag = "PlayerService"
|
||||
private val binder = LocalBinder()
|
||||
|
||||
var clientEventEmitter:ClientEventEmitter? = null
|
||||
@@ -97,7 +103,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
private var channelId = "audiobookshelf_channel"
|
||||
private var channelName = "Audiobookshelf Channel"
|
||||
|
||||
private var currentPlaybackSession:PlaybackSession? = null
|
||||
var currentPlaybackSession:PlaybackSession? = null
|
||||
private var initialPlaybackRate:Float? = null
|
||||
|
||||
private var isAndroidAuto = false
|
||||
@@ -135,6 +141,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@Deprecated("Deprecated in Java")
|
||||
override fun onStart(intent: Intent?, startId: Int) {
|
||||
Log.d(tag, "onStart $startId")
|
||||
}
|
||||
@@ -223,7 +230,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
.apply {
|
||||
setSessionActivity(sessionActivityPendingIntent)
|
||||
isActive = true
|
||||
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
||||
}
|
||||
|
||||
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
|
||||
@@ -250,6 +256,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||
playerNotificationManager.setUseRewindActionInCompactView(true)
|
||||
playerNotificationManager.setSmallIcon(R.drawable.exo_icon_localaudio)
|
||||
|
||||
// Unknown action
|
||||
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||
@@ -270,6 +277,17 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
val coverUri = currentPlaybackSession!!.getCoverUri()
|
||||
|
||||
var bitmap:Bitmap? = null
|
||||
// Local covers get bitmap
|
||||
if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) {
|
||||
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||
} else {
|
||||
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||
ImageDecoder.decodeBitmap(source)
|
||||
}
|
||||
}
|
||||
|
||||
// Fix for local images crashing on Android 11 for specific devices
|
||||
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
||||
try {
|
||||
@@ -282,11 +300,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
Log.e(tag, "Grant uri permission error $error")
|
||||
}
|
||||
|
||||
return MediaDescriptionCompat.Builder()
|
||||
.setMediaId(currentPlaybackSession!!.id)
|
||||
val extra = Bundle()
|
||||
extra.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentPlaybackSession!!.displayAuthor)
|
||||
|
||||
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||
.setExtras(extra)
|
||||
.setTitle(currentPlaybackSession!!.displayTitle)
|
||||
.setSubtitle(currentPlaybackSession!!.displayAuthor)
|
||||
.setIconUri(coverUri).build()
|
||||
.setIconUri(coverUri)
|
||||
|
||||
bitmap?.let {
|
||||
mediaDescriptionBuilder.setIconBitmap(it)
|
||||
}
|
||||
|
||||
return mediaDescriptionBuilder.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -343,12 +369,20 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: When an item isFinished the currentTime should be reset to 0
|
||||
// will reset the time if currentTime is within 5s of duration (for android auto)
|
||||
Log.d(tag, "Prepare Player Session Current Time=${playbackSession.currentTime}, Duration=${playbackSession.duration}")
|
||||
if (playbackSession.duration - playbackSession.currentTime < 5) {
|
||||
Log.d(tag, "Prepare Player Session is finished, so restart it")
|
||||
playbackSession.currentTime = 0.0
|
||||
}
|
||||
|
||||
isClosed = false
|
||||
val customActionProviders = mutableListOf(
|
||||
JumpBackwardCustomActionProvider(),
|
||||
JumpForwardCustomActionProvider(),
|
||||
)
|
||||
val metadata = playbackSession.getMediaMetadataCompat()
|
||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||
@@ -358,9 +392,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
customActionProviders.addAll(listOf(
|
||||
SkipBackwardCustomActionProvider(),
|
||||
SkipForwardCustomActionProvider(),
|
||||
));
|
||||
))
|
||||
}
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||
|
||||
playbackSession.mediaPlayer = getMediaPlayer()
|
||||
|
||||
@@ -464,6 +498,36 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
fun handlePlaybackEnded() {
|
||||
Log.d(tag, "handlePlaybackEnded")
|
||||
if (isAndroidAuto && currentPlaybackSession?.isPodcastEpisode == true) {
|
||||
Log.d(tag, "Podcast playback ended on android auto")
|
||||
val libraryItem = currentPlaybackSession?.libraryItem ?: return
|
||||
|
||||
// Need to sync with server to set as finished
|
||||
mediaProgressSyncer.finished {
|
||||
// Need to reload media progress
|
||||
mediaManager.loadServerUserMediaProgress {
|
||||
val podcast = libraryItem.media as Podcast
|
||||
val nextEpisode = podcast.getNextUnfinishedEpisode(libraryItem.id, mediaManager)
|
||||
Log.d(tag, "handlePlaybackEnded nextEpisode=$nextEpisode")
|
||||
nextEpisode?.let { podcastEpisode ->
|
||||
mediaManager.play(libraryItem, podcastEpisode, getPlayItemRequestPayload(false)) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to play library item")
|
||||
} else {
|
||||
val playbackRate = mediaManager.getSavedPlaybackRate()
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it,true, playbackRate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startNewPlaybackSession() {
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
val forceTranscode = playbackSession.isHLS // If already HLS then force
|
||||
@@ -576,14 +640,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
return currentPlaybackSession?.clone()
|
||||
}
|
||||
|
||||
fun getCurrentPlaybackSessionId() :String? {
|
||||
return currentPlaybackSession?.id
|
||||
}
|
||||
|
||||
fun getCurrentBookChapter():BookChapter? {
|
||||
return currentPlaybackSession?.getChapterForTime(this.getCurrentTime())
|
||||
}
|
||||
|
||||
fun getEndTimeOfChapterOrTrack():Long? {
|
||||
return getCurrentBookChapter()?.endMs ?: currentPlaybackSession?.getCurrentTrackEndTime()
|
||||
}
|
||||
|
||||
private fun getNextBookChapter():BookChapter? {
|
||||
return currentPlaybackSession?.getNextChapterForTime(this.getCurrentTime())
|
||||
}
|
||||
|
||||
fun getEndTimeOfNextChapterOrTrack():Long? {
|
||||
return getNextBookChapter()?.endMs ?: currentPlaybackSession?.getNextTrackEndTime()
|
||||
}
|
||||
|
||||
// Called from PlayerListener play event
|
||||
// check with server if progress has updated since last play and sync progress update
|
||||
fun checkCurrentSessionProgress(seekBackTime:Long):Boolean {
|
||||
@@ -620,7 +692,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
seekPlayer(playbackSession.currentTimeMs)
|
||||
// Should already be playing
|
||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||
mediaProgressSyncer.start()
|
||||
currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
|
||||
clientEventEmitter?.onPlayingUpdate(true)
|
||||
}
|
||||
} else {
|
||||
@@ -628,9 +700,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
if (seekBackTime > 0L) {
|
||||
seekBackward(seekBackTime)
|
||||
}
|
||||
|
||||
// Should already be playing
|
||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||
mediaProgressSyncer.start()
|
||||
currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
|
||||
clientEventEmitter?.onPlayingUpdate(true)
|
||||
}
|
||||
}
|
||||
@@ -639,7 +712,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
// Streaming from server so check if playback session still exists on server
|
||||
Log.d(
|
||||
tag,
|
||||
"checkCurrentSessionProgress: Checking if playback session for server stream is still available"
|
||||
"checkCurrentSessionProgress: Checking if playback session ${playbackSession.id} for server stream is still available"
|
||||
)
|
||||
apiHandler.getPlaybackSession(playbackSession.id) {
|
||||
if (it == null) {
|
||||
@@ -660,7 +733,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||
mediaProgressSyncer.start()
|
||||
mediaProgressSyncer.play(it)
|
||||
clientEventEmitter?.onPlayingUpdate(true)
|
||||
}
|
||||
}
|
||||
@@ -694,14 +767,23 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
fun seekPlayer(time: Long) {
|
||||
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $time")
|
||||
var timeToSeek = time
|
||||
Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $timeToSeek")
|
||||
if (timeToSeek < 0) {
|
||||
Log.w(tag, "seekPlayer invalid time $timeToSeek - setting to 0")
|
||||
timeToSeek = 0L
|
||||
} else if (timeToSeek > getDuration()) {
|
||||
Log.w(tag, "seekPlayer invalid time $timeToSeek - setting to MAX - 2000")
|
||||
timeToSeek = getDuration() - 2000L
|
||||
}
|
||||
|
||||
if (currentPlayer.mediaItemCount > 1) {
|
||||
currentPlaybackSession?.currentTime = time / 1000.0
|
||||
currentPlaybackSession?.currentTime = timeToSeek / 1000.0
|
||||
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
|
||||
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
|
||||
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
|
||||
} else {
|
||||
currentPlayer.seekTo(time)
|
||||
currentPlayer.seekTo(timeToSeek)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -750,10 +832,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
currentPlaybackSession = null
|
||||
mediaProgressSyncer.reset()
|
||||
clientEventEmitter?.onPlaybackClosed()
|
||||
PlayerListener.lastPauseTime = 0
|
||||
isClosed = true
|
||||
stopForeground(true)
|
||||
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
@@ -777,8 +860,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||
}
|
||||
|
||||
@get:JsonIgnore
|
||||
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||
|
||||
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
||||
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
||||
@@ -799,11 +881,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
//
|
||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
||||
//
|
||||
private val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead"
|
||||
private val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator"
|
||||
private val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app"
|
||||
private val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox"
|
||||
private val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant"
|
||||
private val VALID_MEDIA_BROWSERS = mutableListOf("com.audiobookshelf.app", ANDROID_AUTO_PKG_NAME, ANDROID_AUTO_SIMULATOR_PKG_NAME, ANDROID_WEARABLE_PKG_NAME, ANDROID_GSEARCH_PKG_NAME, ANDROID_AUTOMOTIVE_PKG_NAME)
|
||||
|
||||
private val AUTO_MEDIA_ROOT = "/"
|
||||
@@ -856,7 +933,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
||||
Log.d(tag, "Loading podcast episodes")
|
||||
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) {
|
||||
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
|
||||
result.sendResult(it)
|
||||
}
|
||||
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
||||
@@ -864,11 +941,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||
val children = libraryItems.map { libraryItem ->
|
||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
||||
val mediaDescription = libraryItem.getMediaDescription(null)
|
||||
val mediaDescription = libraryItem.getMediaDescription(null, ctx)
|
||||
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
} else {
|
||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
||||
val description = libraryItem.getMediaDescription(progress)
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id)
|
||||
libraryItem.localLibraryItemId = localLibraryItem?.id
|
||||
val description = libraryItem.getMediaDescription(progress, ctx)
|
||||
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
}
|
||||
@@ -882,13 +961,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
localBooks.forEach { localLibraryItem ->
|
||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
||||
val description = localLibraryItem.getMediaDescription(progress)
|
||||
val description = localLibraryItem.getMediaDescription(progress, ctx)
|
||||
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
|
||||
localPodcasts.forEach { localLibraryItem ->
|
||||
val mediaDescription = localLibraryItem.getMediaDescription(null)
|
||||
val mediaDescription = localLibraryItem.getMediaDescription(null, ctx)
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||
}
|
||||
|
||||
@@ -904,15 +983,26 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
||||
} else {
|
||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
||||
|
||||
// to show download icon
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
|
||||
localLibraryItem?.let { lli ->
|
||||
val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == itemInProgress.episode.id }
|
||||
itemInProgress.episode.localEpisodeId = localEpisode?.id
|
||||
}
|
||||
|
||||
}
|
||||
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper,progress)
|
||||
mediaDescription = itemInProgress.episode.getMediaDescription(itemInProgress.libraryItemWrapper, progress, ctx)
|
||||
} else {
|
||||
if (itemInProgress.isLocal) {
|
||||
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
|
||||
} else {
|
||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
|
||||
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(itemInProgress.libraryItemWrapper.id)
|
||||
(itemInProgress.libraryItemWrapper as LibraryItem).localLibraryItemId = localLibraryItem?.id // To show downloaded icon
|
||||
}
|
||||
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress)
|
||||
mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress, ctx)
|
||||
}
|
||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||
}
|
||||
@@ -961,7 +1051,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
// Shake sensor used for sleep timer
|
||||
fun registerSensor() {
|
||||
if (isShakeSensorRegistered) {
|
||||
Log.w(tag, "Shake sensor already registered")
|
||||
Log.i(tag, "Shake sensor already registered")
|
||||
return
|
||||
}
|
||||
shakeSensorUnregisterTask?.cancel()
|
||||
@@ -981,7 +1071,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
// Unregister shake sensor after wake up expiration
|
||||
shakeSensorUnregisterTask?.cancel()
|
||||
shakeSensorUnregisterTask = Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "wake time expired: Unregistering shake sensor")
|
||||
mSensorManager!!.unregisterListener(mShakeDetector)
|
||||
isShakeSensorRegistered = false
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class ShakeDetector : SensorEventListener {
|
||||
@@ -34,7 +35,8 @@ class ShakeDetector : SensorEventListener {
|
||||
val gZ = z / SensorManager.GRAVITY_EARTH
|
||||
// gForce will be close to 1 when there is no movement.
|
||||
val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ)
|
||||
if (gForce > SHAKE_THRESHOLD_GRAVITY) {
|
||||
val shakeThreshold = DeviceManager.deviceData.deviceSettings?.getShakeThresholdGravity() ?: 1.6f
|
||||
if (gForce > shakeThreshold) {
|
||||
val now = System.currentTimeMillis()
|
||||
// ignore shake events too close to each other (500ms)
|
||||
if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) {
|
||||
@@ -59,7 +61,7 @@ class ShakeDetector : SensorEventListener {
|
||||
* from the Google Play Store and run it to see how
|
||||
* many G's it takes to register a shake
|
||||
*/
|
||||
private const val SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f
|
||||
// var SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f
|
||||
private const val SHAKE_SLOP_TIME_MS = 500
|
||||
private const val SHAKE_COUNT_RESET_TIME_MS = 3000
|
||||
}
|
||||
|
||||
@@ -6,7 +6,9 @@ import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.player.CastManager
|
||||
import com.audiobookshelf.app.player.PlayerListener
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
@@ -21,7 +23,7 @@ import org.json.JSONObject
|
||||
@CapacitorPlugin(name = "AbsAudioPlayer")
|
||||
class AbsAudioPlayer : Plugin() {
|
||||
private val tag = "AbsAudioPlayer"
|
||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
private lateinit var mainActivity: MainActivity
|
||||
private lateinit var apiHandler:ApiHandler
|
||||
@@ -88,7 +90,13 @@ class AbsAudioPlayer : Plugin() {
|
||||
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
|
||||
emit("onNetworkMeteredChanged", isUnmetered)
|
||||
}
|
||||
|
||||
override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) {
|
||||
notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
|
||||
}
|
||||
})
|
||||
|
||||
MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter
|
||||
}
|
||||
mainActivity.pluginCallback = foregroundServiceReady
|
||||
}
|
||||
@@ -160,6 +168,8 @@ class AbsAudioPlayer : Plugin() {
|
||||
val episodeId = call.getString("episodeId", "").toString()
|
||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||
val startTimeOverride = call.getDouble("startTime")
|
||||
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride")
|
||||
|
||||
if (libraryItemId.isEmpty()) {
|
||||
Log.e(tag, "Invalid call to play library item no library item id")
|
||||
@@ -181,10 +191,16 @@ class AbsAudioPlayer : Plugin() {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||
val playbackSession = it.getPlaybackSession(episode)
|
||||
if (startTimeOverride != null) {
|
||||
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
||||
playbackSession.currentTime = startTimeOverride
|
||||
}
|
||||
|
||||
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
|
||||
playerNotificationService.mediaProgressSyncer.stop {
|
||||
Log.d(tag, "Media progress syncer was already syncing - stopped")
|
||||
PlayerListener.lazyIsPlaying = false
|
||||
|
||||
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
|
||||
playerNotificationService.preparePlayer(
|
||||
playbackSession,
|
||||
@@ -194,6 +210,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
playerNotificationService.mediaProgressSyncer.reset()
|
||||
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
|
||||
}
|
||||
}
|
||||
@@ -201,28 +218,25 @@ class AbsAudioPlayer : Plugin() {
|
||||
}
|
||||
} else { // Play library item from server
|
||||
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
|
||||
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
|
||||
} else {
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
|
||||
|
||||
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
|
||||
playerNotificationService.mediaProgressSyncer.stop {
|
||||
Log.d(tag, "Media progress syncer was already syncing - stopped")
|
||||
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
}
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.mediaProgressSyncer.stop {
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) {
|
||||
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
|
||||
} else {
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
if (startTimeOverride != null) {
|
||||
Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride")
|
||||
it.currentTime = startTimeOverride
|
||||
}
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}")
|
||||
PlayerListener.lazyIsPlaying = false
|
||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||
}
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
}
|
||||
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -267,6 +281,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
@PluginMethod
|
||||
fun seek(call: PluginCall) {
|
||||
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
||||
Log.d(tag, "seek action to $time")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
|
||||
call.resolve()
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.audiobookshelf.app.data
|
||||
package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
@@ -12,12 +14,11 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import org.json.JSONObject
|
||||
|
||||
@CapacitorPlugin(name = "AbsDatabase")
|
||||
class AbsDatabase : Plugin() {
|
||||
val tag = "AbsDatabase"
|
||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
lateinit var apiHandler: ApiHandler
|
||||
@@ -189,6 +190,24 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getLocalMediaProgressForServerItem(call:PluginCall) {
|
||||
val libraryItemId = call.getString("libraryItemId", "").toString()
|
||||
var episodeId:String? = call.getString("episodeId", "").toString()
|
||||
if (episodeId == "") episodeId = null
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress()
|
||||
val localMediaProgress = allLocalMediaProgress.find { libraryItemId == it.libraryItemId && (episodeId == null || it.episodeId == episodeId) }
|
||||
|
||||
if (localMediaProgress == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun removeLocalMediaProgress(call:PluginCall) {
|
||||
val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
|
||||
@@ -257,6 +276,8 @@ class AbsDatabase : Plugin() {
|
||||
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
|
||||
call.resolve()
|
||||
} else {
|
||||
MediaEventManager.syncEvent(mediaProgress, "Received from webhook event")
|
||||
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
@@ -287,8 +308,8 @@ class AbsDatabase : Plugin() {
|
||||
return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||
}
|
||||
|
||||
var duration = 0.0
|
||||
var podcastEpisode:PodcastEpisode? = null
|
||||
val duration: Double
|
||||
var podcastEpisode: PodcastEpisode? = null
|
||||
if (!localEpisodeId.isNullOrEmpty()) {
|
||||
val podcast = localLibraryItem.media as Podcast
|
||||
podcastEpisode = podcast.episodes?.find { episode ->
|
||||
@@ -407,4 +428,19 @@ class AbsDatabase : Plugin() {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getMediaItemHistory(call:PluginCall) { // Returns device data
|
||||
Log.d(tag, "getMediaItemHistory ${call.data}")
|
||||
val mediaId = call.getString("mediaId") ?: ""
|
||||
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
val mediaItemHistory = DeviceManager.dbManager.getMediaItemHistory(mediaId)
|
||||
if (mediaItemHistory == null) {
|
||||
call.resolve()
|
||||
} else {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,20 +2,17 @@ package com.audiobookshelf.app.plugins
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.callback.FileCallback
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.anggrayudi.storage.media.FileDescription
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.device.FolderScanner
|
||||
import com.audiobookshelf.app.models.DownloadItem
|
||||
import com.audiobookshelf.app.models.DownloadItemPart
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.audiobookshelf.app.managers.DownloadItemManager
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
import com.getcapacitor.JSObject
|
||||
@@ -23,108 +20,37 @@ import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
@CapacitorPlugin(name = "AbsDownloader")
|
||||
class AbsDownloader : Plugin() {
|
||||
private val tag = "AbsDownloader"
|
||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
lateinit var downloadManager: DownloadManager
|
||||
lateinit var apiHandler: ApiHandler
|
||||
lateinit var folderScanner: FolderScanner
|
||||
lateinit var downloadItemManager: DownloadItemManager
|
||||
|
||||
data class DownloadItemPart(
|
||||
val id: String,
|
||||
val filename: String,
|
||||
val finalDestinationPath:String,
|
||||
val itemTitle: String,
|
||||
val serverPath: String,
|
||||
val localFolderName: String,
|
||||
val localFolderUrl: String,
|
||||
val localFolderId: String,
|
||||
val audioTrack: AudioTrack?,
|
||||
val episode:PodcastEpisode?,
|
||||
var completed:Boolean,
|
||||
var moved:Boolean,
|
||||
var failed:Boolean,
|
||||
@JsonIgnore val uri: Uri,
|
||||
@JsonIgnore val destinationUri: Uri,
|
||||
@JsonIgnore val finalDestinationUri: Uri,
|
||||
var downloadId: Long?,
|
||||
var progress: Long
|
||||
) {
|
||||
companion object {
|
||||
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)
|
||||
|
||||
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),
|
||||
filename = filename, finalDestinationFile.absolutePath,
|
||||
itemTitle = itemTitle,
|
||||
serverPath = serverPath,
|
||||
localFolderName = localFolder.name,
|
||||
localFolderUrl = localFolder.contentUrl,
|
||||
localFolderId = localFolder.id,
|
||||
audioTrack = audioTrack,
|
||||
episode = episode,
|
||||
completed = false,
|
||||
moved = false,
|
||||
failed = false,
|
||||
uri = downloadUri,
|
||||
destinationUri = destinationUri,
|
||||
finalDestinationUri = finalDestinationUri,
|
||||
downloadId = null,
|
||||
progress = 0
|
||||
)
|
||||
}
|
||||
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
|
||||
override fun onDownloadItem(downloadItem:DownloadItem) {
|
||||
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun getDownloadRequest(): DownloadManager.Request {
|
||||
val dlRequest = DownloadManager.Request(uri)
|
||||
dlRequest.setTitle(filename)
|
||||
dlRequest.setDescription("Downloading to $localFolderName for book $itemTitle")
|
||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
dlRequest.setDestinationUri(destinationUri)
|
||||
return dlRequest
|
||||
override fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart) {
|
||||
notifyListeners("onDownloadItemPartUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItemPart)))
|
||||
}
|
||||
}
|
||||
|
||||
data class DownloadItem(
|
||||
val id: String,
|
||||
val libraryItemId:String,
|
||||
val episodeId:String?,
|
||||
val userMediaProgress:MediaProgress?,
|
||||
val serverConnectionConfigId:String,
|
||||
val serverAddress:String,
|
||||
val serverUserId:String,
|
||||
val mediaType: String,
|
||||
val itemFolderPath:String,
|
||||
val localFolder: LocalFolder,
|
||||
val itemTitle: String,
|
||||
val media:MediaType,
|
||||
val downloadItemParts: MutableList<DownloadItemPart>
|
||||
)
|
||||
|
||||
var downloadQueue: MutableList<DownloadItem> = mutableListOf()
|
||||
override fun onDownloadItemComplete(jsobj:JSObject) {
|
||||
notifyListeners("onItemDownloadComplete", jsobj)
|
||||
}
|
||||
})
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
folderScanner = FolderScanner(mainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
|
||||
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
|
||||
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
|
||||
}
|
||||
|
||||
@@ -137,7 +63,7 @@ class AbsDownloader : Plugin() {
|
||||
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
||||
|
||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||
if (downloadQueue.find { it.id == downloadId } != null) {
|
||||
if (downloadItemManager.downloadItemQueue.find { it.id == downloadId } != null) {
|
||||
Log.d(tag, "Download already started for this media entity $downloadId")
|
||||
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
|
||||
}
|
||||
@@ -205,74 +131,84 @@ class AbsDownloader : Plugin() {
|
||||
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||
|
||||
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||
|
||||
if (libraryItem.mediaType == "book") {
|
||||
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
|
||||
|
||||
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, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, libraryItem.media, mutableListOf())
|
||||
val itemSubfolder = "$bookAuthor/$bookTitle"
|
||||
val itemFolderPath = "${localFolder.absolutePath}/$itemSubfolder"
|
||||
val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf())
|
||||
|
||||
// Create download item part for each audio track
|
||||
tracks.forEach { audioTrack ->
|
||||
val fileSize = audioTrack.metadata?.size ?: 0
|
||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
||||
val destinationFilename = getFilenameFromRelPath(audioTrack.relPath)
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,audioTrack,null)
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,audioTrack,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
}
|
||||
|
||||
if (downloadItem.downloadItemParts.isNotEmpty()) {
|
||||
// Add cover download item
|
||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||
val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath }
|
||||
val coverFileSize = coverLibraryFile?.metadata?.size ?: 0
|
||||
|
||||
val serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
val destinationFilename = "cover.jpg"
|
||||
val destinationFilename = "cover-${libraryItem.id}.jpg"
|
||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
val finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
|
||||
if (destinationFile.exists()) {
|
||||
Log.d(tag, "TEMP Audio file already exists, removing it from ${destinationFile.absolutePath}")
|
||||
destinationFile.delete()
|
||||
}
|
||||
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
val downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,bookTitle,serverPath,localFolder,null,null)
|
||||
val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
||||
val downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
}
|
||||
|
||||
downloadQueue.add(downloadItem)
|
||||
startWatchingDownloads(downloadItem)
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
}
|
||||
} else {
|
||||
// Podcast episode download
|
||||
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||
|
||||
val audioTrack = episode?.audioTrack
|
||||
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||
|
||||
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, libraryItem.userMediaProgress, 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, podcastTitle, libraryItem.media, mutableListOf())
|
||||
|
||||
var serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
|
||||
var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "")
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName ${destinationFilename}")
|
||||
Log.d(tag, "Audio File Server Path $serverPath | AF RelPath ${audioTrack?.relPath} | LocalFolder Path ${localFolder.absolutePath} | DestName $destinationFilename")
|
||||
|
||||
var destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||
@@ -281,14 +217,13 @@ class AbsDownloader : Plugin() {
|
||||
finalDestinationFile.delete()
|
||||
}
|
||||
|
||||
var downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
|
||||
var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,audioTrack,episode)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
var dlRequest = downloadItemPart.getDownloadRequest()
|
||||
var downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
|
||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
||||
val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath }
|
||||
val coverFileSize = coverLibraryFile?.metadata?.size ?: 0
|
||||
|
||||
serverPath = "/api/items/${libraryItem.id}/cover"
|
||||
destinationFilename = "cover.jpg"
|
||||
|
||||
@@ -298,134 +233,12 @@ class AbsDownloader : Plugin() {
|
||||
if (finalDestinationFile.exists()) {
|
||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||
} else {
|
||||
downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
||||
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||
|
||||
dlRequest = downloadItemPart.getDownloadRequest()
|
||||
downloadId = downloadManager.enqueue(dlRequest)
|
||||
downloadItemPart.downloadId = downloadId
|
||||
}
|
||||
}
|
||||
|
||||
downloadQueue.add(downloadItem)
|
||||
startWatchingDownloads(downloadItem)
|
||||
DeviceManager.dbManager.saveDownloadItem(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
|
||||
checkDownloads(downloadItem)
|
||||
|
||||
// Keep database updated as item parts finish downloading
|
||||
if (downloadItem.downloadItemParts.size > 0 && downloadItem.downloadItemParts.size != numPartsBefore) {
|
||||
Log.d(tag, "Save download item on num parts changed from $numPartsBefore to ${downloadItem.downloadItemParts.size}")
|
||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
||||
}
|
||||
|
||||
notifyListeners("onItemDownloadUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
|
||||
delay(500)
|
||||
}
|
||||
|
||||
// Remove download notifications
|
||||
downloadItem.downloadItemParts.forEach { downloadItemPart ->
|
||||
downloadItemPart.downloadId?.let {
|
||||
downloadManager.remove(it)
|
||||
}
|
||||
}
|
||||
|
||||
val downloadItemScanResult = folderScanner.scanDownloadItem(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
downloadQueue.remove(downloadItem)
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkDownloads(downloadItem: DownloadItem) {
|
||||
val itemParts = downloadItem.downloadItemParts.map { it }
|
||||
for (downloadItemPart in itemParts) {
|
||||
if (downloadItemPart.downloadId != null) {
|
||||
val dlid = downloadItemPart.downloadId!!
|
||||
val query = DownloadManager.Query().setFilterById(dlid)
|
||||
downloadManager.query(query).use {
|
||||
if (it.moveToFirst()) {
|
||||
val bytesColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)
|
||||
val statusColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val bytesDownloadedColumnIndex = it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)
|
||||
|
||||
val totalBytes = if (bytesColumnIndex >= 0) it.getInt(bytesColumnIndex) else 0
|
||||
val downloadStatus = if (statusColumnIndex >= 0) it.getInt(statusColumnIndex) else 0
|
||||
val bytesDownloadedSoFar = if (bytesDownloadedColumnIndex >= 0) it.getInt(bytesDownloadedColumnIndex) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} bytes $totalBytes | bytes dled $bytesDownloadedSoFar | downloadStatus $downloadStatus")
|
||||
|
||||
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
// Once file download is complete move the file to the final destination
|
||||
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)}")
|
||||
|
||||
val fcb = object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "DOWNLOAD: PREPARING MOVE FILE")
|
||||
}
|
||||
override fun onFailed(errorCode:ErrorCode) {
|
||||
Log.e(tag, "DOWNLOAD: FAILED TO MOVE FILE $errorCode")
|
||||
downloadItemPart.failed = true
|
||||
file?.delete()
|
||||
}
|
||||
override fun onCompleted(result:Any) {
|
||||
Log.d(tag, "DOWNLOAD: FILE MOVE COMPLETED")
|
||||
val resultDocFile = result as DocumentFile
|
||||
Log.d(tag, "DOWNLOAD: COMPLETED FILE INFO ${resultDocFile.getAbsolutePath(mainActivity)}")
|
||||
downloadItemPart.moved = true
|
||||
}
|
||||
}
|
||||
|
||||
Log.d(tag, "DOWNLOAD: Move file to final destination path: ${downloadItemPart.finalDestinationPath}")
|
||||
val localFolderFile = DocumentFileCompat.fromUri(mainActivity,Uri.parse(downloadItemPart.localFolderUrl))
|
||||
if (localFolderFile == null) {
|
||||
Log.e(tag, "Local Folder File from uri is null")
|
||||
} else {
|
||||
val mimetype = if (downloadItemPart.audioTrack != null) MimeType.AUDIO else MimeType.IMAGE
|
||||
val fileDescription = FileDescription(downloadItemPart.filename, downloadItemPart.itemTitle, mimetype)
|
||||
file?.moveFileTo(mainActivity,localFolderFile,fileDescription,fcb)
|
||||
}
|
||||
} else {
|
||||
// Why is kotlin requiring an else here..
|
||||
}
|
||||
} else if (downloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Failed")
|
||||
downloadItem.downloadItemParts.remove(downloadItemPart)
|
||||
} else {
|
||||
//update progress
|
||||
val percentProgress = if (totalBytes > 0) ((bytesDownloadedSoFar * 100L) / totalBytes) else 0
|
||||
Log.d(tag, "checkDownloads Download ${downloadItemPart.filename} Progress = $percentProgress%")
|
||||
downloadItemPart.progress = percentProgress
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "Download ${downloadItemPart.filename} not found in dlmanager")
|
||||
downloadItem.downloadItemParts.remove(downloadItemPart)
|
||||
}
|
||||
}
|
||||
}
|
||||
downloadItemManager.addDownloadItem(downloadItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import kotlinx.coroutines.launch
|
||||
class AbsFileSystem : Plugin() {
|
||||
private val TAG = "AbsFileSystem"
|
||||
private val tag = "AbsFileSystem"
|
||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
lateinit var mainActivity: MainActivity
|
||||
|
||||
@@ -85,21 +85,28 @@ class AbsFileSystem : Plugin() {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
||||
}
|
||||
|
||||
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
|
||||
override fun onStorageAccessDenied(
|
||||
requestCode: Int,
|
||||
folder: DocumentFile?,
|
||||
storageType: StorageType,
|
||||
storageId: String
|
||||
) {
|
||||
Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}")
|
||||
|
||||
val jsobj = JSObject()
|
||||
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
builder.setMessage(
|
||||
"You have no write access to this storage, thus selecting this folder is useless." +
|
||||
"\nWould you like to grant access to this folder?")
|
||||
"\nWould you like to grant access to this folder?")
|
||||
builder.setNegativeButton("Dont Allow") { _, _ ->
|
||||
run {
|
||||
jsobj.put("error", "User Canceled, Access Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, storageType) }
|
||||
builder.setPositiveButton("Allow.") { _, _ -> mainActivity.storageHelper.requestStorageAccess(REQUEST_CODE_SDCARD_ACCESS, initialPath = FileFullPath(mainActivity, storageId, "")) }
|
||||
builder.show()
|
||||
} else {
|
||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||
@@ -131,7 +138,7 @@ class AbsFileSystem : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun checkStoragePermission(call: PluginCall) {
|
||||
var res: Boolean
|
||||
val res: Boolean
|
||||
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
||||
res = SimpleStorage.hasStoragePermission(context)
|
||||
Log.d(TAG, "checkStoragePermission: Check Storage Access $res")
|
||||
@@ -140,33 +147,33 @@ class AbsFileSystem : Plugin() {
|
||||
res = true
|
||||
}
|
||||
|
||||
var jsobj = JSObject()
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("value", res)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun checkFolderPermissions(call: PluginCall) {
|
||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
||||
val folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
||||
|
||||
var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
|
||||
val hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
|
||||
|
||||
var jsobj = JSObject()
|
||||
val jsobj = JSObject()
|
||||
jsobj.put("value", hasAccess)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun scanFolder(call: PluginCall) {
|
||||
var folderId = call.data.getString("folderId", "").toString()
|
||||
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
val folderId = call.data.getString("folderId", "").toString()
|
||||
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $forceAudioProbe")
|
||||
|
||||
var folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
val folder: LocalFolder? = DeviceManager.dbManager.getLocalFolder(folderId)
|
||||
folder?.let {
|
||||
var folderScanner = FolderScanner(context)
|
||||
var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
|
||||
val folderScanner = FolderScanner(context)
|
||||
val folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
|
||||
if (folderScanResult == null) {
|
||||
Log.d(TAG, "NO Scan DATA")
|
||||
return call.resolve(JSObject())
|
||||
@@ -179,28 +186,28 @@ class AbsFileSystem : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun removeFolder(call: PluginCall) {
|
||||
var folderId = call.data.getString("folderId", "").toString()
|
||||
val folderId = call.data.getString("folderId", "").toString()
|
||||
DeviceManager.dbManager.removeLocalFolder(folderId)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun removeLocalLibraryItem(call: PluginCall) {
|
||||
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun scanLocalLibraryItem(call: PluginCall) {
|
||||
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
var localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
val localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
localLibraryItem?.let {
|
||||
var folderScanner = FolderScanner(context)
|
||||
var scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
|
||||
val folderScanner = FolderScanner(context)
|
||||
val scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
|
||||
if (scanResult == null) {
|
||||
Log.d(TAG, "NO Scan DATA")
|
||||
call.resolve(JSObject())
|
||||
@@ -214,13 +221,13 @@ class AbsFileSystem : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun deleteItem(call: PluginCall) {
|
||||
var localLibraryItemId = call.data.getString("id", "").toString()
|
||||
var absolutePath = call.data.getString("absolutePath", "").toString()
|
||||
var contentUrl = call.data.getString("contentUrl", "").toString()
|
||||
val localLibraryItemId = call.data.getString("id", "").toString()
|
||||
val absolutePath = call.data.getString("absolutePath", "").toString()
|
||||
val contentUrl = call.data.getString("contentUrl", "").toString()
|
||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
||||
|
||||
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
var success = docfile?.delete() == true
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
val success = docfile?.delete() == true
|
||||
if (success) {
|
||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||
}
|
||||
@@ -229,19 +236,19 @@ class AbsFileSystem : Plugin() {
|
||||
|
||||
@PluginMethod
|
||||
fun deleteTrackFromItem(call: PluginCall) {
|
||||
var localLibraryItemId = call.data.getString("id", "").toString()
|
||||
var trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
|
||||
var contentUrl = call.data.getString("trackContentUrl", "").toString()
|
||||
val localLibraryItemId = call.data.getString("id", "").toString()
|
||||
val trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
|
||||
val contentUrl = call.data.getString("trackContentUrl", "").toString()
|
||||
Log.d(tag, "deleteTrackFromItem $contentUrl")
|
||||
|
||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||
if (localLibraryItem == null) {
|
||||
Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId")
|
||||
return call.resolve(JSObject("{\"success\":false}"))
|
||||
}
|
||||
|
||||
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
var success = docfile?.delete() == true
|
||||
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||
val success = docfile?.delete() == true
|
||||
if (success) {
|
||||
localLibraryItem.media.removeAudioTrack(trackLocalFileId)
|
||||
localLibraryItem.removeLocalFile(trackLocalFileId)
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
@@ -31,8 +32,8 @@ class ApiHandler(var ctx:Context) {
|
||||
|
||||
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
|
||||
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int)
|
||||
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>, val serverProgressUpdates:List<MediaProgress>)
|
||||
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int, var serverProgressUpdates:List<MediaProgress>)
|
||||
|
||||
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
val address = config?.address ?: DeviceManager.serverAddress
|
||||
@@ -138,12 +139,16 @@ class ApiHandler(var ctx:Context) {
|
||||
val mapper = jacksonMapper
|
||||
getRequest("/api/libraries", null,null) {
|
||||
val libraries = mutableListOf<Library>()
|
||||
if (it.has("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)
|
||||
}
|
||||
|
||||
var array = JSONArray()
|
||||
if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this
|
||||
array = it.getJSONArray("libraries")
|
||||
} else if (it.has("value")) {
|
||||
array = it.getJSONArray("value")
|
||||
}
|
||||
|
||||
for (i in 0 until array.length()) {
|
||||
libraries.add(mapper.readValue(array.get(i).toString()))
|
||||
}
|
||||
cb(libraries)
|
||||
}
|
||||
@@ -222,26 +227,26 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean) -> Unit) {
|
||||
fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
|
||||
|
||||
postRequest("/api/session/$sessionId/sync", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false)
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
cb(true)
|
||||
cb(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean) -> Unit) {
|
||||
fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
||||
|
||||
postRequest("/api/session/local", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false)
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
cb(true)
|
||||
cb(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -249,7 +254,7 @@ class ApiHandler(var ctx:Context) {
|
||||
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
|
||||
if (!isOnline()) {
|
||||
Log.d(tag, "Error not online")
|
||||
cb(LocalMediaProgressSyncResultsPayload(0,0,0))
|
||||
cb(LocalMediaProgressSyncResultsPayload(0,0,0, mutableListOf()))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -258,7 +263,7 @@ class ApiHandler(var ctx:Context) {
|
||||
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
|
||||
}
|
||||
|
||||
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0)
|
||||
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0, mutableListOf())
|
||||
|
||||
if (localMediaProgress.isNotEmpty()) {
|
||||
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
||||
@@ -274,14 +279,21 @@ class ApiHandler(var ctx:Context) {
|
||||
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
|
||||
|
||||
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
|
||||
localSyncResultsPayload.serverProgressUpdates = progressSyncResponsePayload.serverProgressUpdates
|
||||
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
|
||||
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
|
||||
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
|
||||
// Update all local media progress
|
||||
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
|
||||
MediaEventManager.syncEvent(localMediaProgress, "Local progress updated. Received from server sync local API request")
|
||||
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
}
|
||||
}
|
||||
|
||||
progressSyncResponsePayload.serverProgressUpdates.forEach { localMediaProgress ->
|
||||
MediaEventManager.syncEvent(localMediaProgress, "Server progress updated. Received from server sync local API request")
|
||||
}
|
||||
}
|
||||
|
||||
cb(localSyncResultsPayload)
|
||||
@@ -317,6 +329,7 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
|
||||
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
|
||||
Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}")
|
||||
val endpoint = "/api/session/$playbackSessionId"
|
||||
getRequest(endpoint, null, null) {
|
||||
val err = it.getString("error")
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<item name="colorAccent">@color/colorAccent</item>
|
||||
</style>
|
||||
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.NoActionBar">
|
||||
<style name="AppTheme.NoActionBar" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
<item name="android:background">@null</item>
|
||||
@@ -18,7 +18,7 @@
|
||||
</style>
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||
<!-- <item name="android:background">@drawable/screen</item>-->
|
||||
</style>
|
||||
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||
<access origin="*" />
|
||||
|
||||
<feature name="CDVOrientation">
|
||||
<param name="android-package" value="cordova.plugins.screenorientation.CDVOrientation"/>
|
||||
</feature>
|
||||
|
||||
|
||||
</widget>
|
||||
@@ -1,15 +1,15 @@
|
||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||
|
||||
buildscript {
|
||||
ext.kotlin_version = '1.5.30'
|
||||
ext.kotlin_version = '1.7.20'
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.google.gms:google-services:4.3.10'
|
||||
classpath 'com.android.tools.build:gradle:7.2.2'
|
||||
classpath 'com.google.gms:google-services:4.3.13'
|
||||
classpath 'com.android.tools.build:gradle:7.4.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
|
||||
// NOTE: Do not place your application dependencies here; they belong
|
||||
@@ -29,3 +29,4 @@ allprojects {
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-network'
|
||||
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
|
||||
|
||||
include ':capacitor-preferences'
|
||||
project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
include ':capacitor-storage'
|
||||
project(':capacitor-storage').projectDir = new File('../node_modules/@capacitor/storage/android')
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#Sun Apr 17 13:28:55 CDT 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-all.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
+12
-10
@@ -1,16 +1,16 @@
|
||||
ext {
|
||||
minSdkVersion = 24
|
||||
compileSdkVersion = 31
|
||||
targetSdkVersion = 30
|
||||
androidxActivityVersion = '1.2.0'
|
||||
androidxAppCompatVersion = '1.4.1'
|
||||
compileSdkVersion = 33
|
||||
targetSdkVersion = 32
|
||||
androidxActivityVersion = '1.4.0'
|
||||
androidxAppCompatVersion = '1.4.2'
|
||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||
androidxCoreVersion = '1.6.0'
|
||||
androidxCoreVersion = '1.8.0'
|
||||
androidPlayCore = '1.9.0'
|
||||
androidxFragmentVersion = '1.3.0'
|
||||
junitVersion = '4.13.1'
|
||||
androidxJunitVersion = '1.1.2'
|
||||
androidxEspressoCoreVersion = '3.3.0'
|
||||
androidxFragmentVersion = '1.4.1'
|
||||
junitVersion = '4.13.2'
|
||||
androidxJunitVersion = '1.1.3'
|
||||
androidxEspressoCoreVersion = '3.4.0'
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
androidx_car_version = '1.0.0-alpha7'
|
||||
androidx_core_ktx_version = '1.7.0'
|
||||
@@ -27,11 +27,13 @@ ext {
|
||||
gradle_version = '3.1.4'
|
||||
gson_version = '2.8.5'
|
||||
junit_version = '4.13'
|
||||
kotlin_version = '1.5.30'
|
||||
kotlin_version = '1.7.20'
|
||||
kotlin_coroutines_version = '1.1.0'
|
||||
multidex_version = '1.0.3'
|
||||
play_services_auth_version = '18.1.0'
|
||||
recycler_view_version = '1.1.0'
|
||||
robolectric_version = '4.2'
|
||||
test_runner_version = '1.1.0'
|
||||
coreSplashScreenVersion = '1.0.0-rc01'
|
||||
androidxWebkitVersion = '1.4.0'
|
||||
}
|
||||
|
||||
+16
-1
@@ -13,7 +13,6 @@
|
||||
.abs-icons {
|
||||
/* use !important to prevent issues with browser extensions that change fonts */
|
||||
font-family: 'absicons' !important;
|
||||
speak: never;
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
font-variant: normal;
|
||||
@@ -25,6 +24,18 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-list:before {
|
||||
content: "\e907";
|
||||
}
|
||||
|
||||
.icon-home:before {
|
||||
content: "\e909";
|
||||
}
|
||||
|
||||
.icon-authors:before {
|
||||
content: "\e90a";
|
||||
}
|
||||
|
||||
.icon-books-1:before {
|
||||
content: "\e905";
|
||||
}
|
||||
@@ -53,6 +64,10 @@
|
||||
content: "\e901";
|
||||
}
|
||||
|
||||
.icon-columns:before {
|
||||
content: "\e90b";
|
||||
}
|
||||
|
||||
.icon-headphones:before {
|
||||
content: "\e910";
|
||||
}
|
||||
|
||||
+2
-2
@@ -2,14 +2,14 @@
|
||||
font-family: 'Material Icons';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
||||
src: url(/fonts/MaterialIcons-Regular.ttf) format('truetype');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Material Icons Outlined';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
||||
src: url(/fonts/MaterialIconsOutlined-Regular.otf) format('opentype');
|
||||
}
|
||||
|
||||
.material-icons {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-full h-16 bg-primary relative">
|
||||
<div id="appbar" class="absolute top-0 left-0 w-full h-full z-10 flex items-center px-2">
|
||||
<div class="w-full h-16 bg-primary relative z-20">
|
||||
<div id="appbar" class="absolute top-0 left-0 w-full h-full flex items-center px-2">
|
||||
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
||||
<img src="/Logo.png" class="h-10 w-10" />
|
||||
</nuxt-link>
|
||||
@@ -21,12 +21,12 @@
|
||||
<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 mt-1.5">
|
||||
<span class="material-icons text-2xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
||||
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center pt-0.5" @click="castClick">
|
||||
<span class="material-icons" :class="isCasting ? 'text-success' : ''">cast</span>
|
||||
</div>
|
||||
|
||||
<nuxt-link v-if="user" class="h-7 mx-1.5" to="/search">
|
||||
<span class="material-icons" style="font-size: 1.75rem">search</span>
|
||||
<nuxt-link v-if="user" class="h-7 mx-1.5" style="padding-top: 3px" to="/search">
|
||||
<span class="material-icons">search</span>
|
||||
</nuxt-link>
|
||||
|
||||
<div class="h-7 mx-1.5">
|
||||
|
||||
+203
-116
@@ -1,19 +1,21 @@
|
||||
<template>
|
||||
<div v-if="playbackSession" id="streamContainer" class="playerContainer fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
|
||||
<div v-if="showFullscreen" class="w-full h-full z-10 bg-bg absolute top-0 left-0 pointer-events-auto">
|
||||
<div class="top-2 left-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
|
||||
<div v-if="playbackSession" id="streamContainer" class="fixed top-0 left-0 layout-wrapper right-0 z-50 pointer-events-none" :class="{ fullscreen: showFullscreen, 'ios-player': $platform === 'ios', 'web-player': $platform === 'web' }">
|
||||
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
|
||||
<div class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(169deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%)" />
|
||||
|
||||
<div class="top-4 left-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-5xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="collapseFullscreen">expand_more</span>
|
||||
</div>
|
||||
<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 v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="isCasting ? (coverBgIsLight ? 'text-successDark' : 'text-success') : coverBgIsLight ? 'text-black' : ''" @click="castClick">cast</span>
|
||||
</div>
|
||||
<div class="top-4 right-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
<div class="top-6 right-4 absolute cursor-pointer">
|
||||
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||
</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>
|
||||
<p class="top-4 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">
|
||||
<div v-if="useChapterTrack && useTotalTrack && showFullscreen" class="absolute total-track w-full px-3 z-30">
|
||||
<div class="flex">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -29,8 +31,8 @@
|
||||
</div>
|
||||
|
||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
||||
<div class="cover-container bookCoverWrapper bg-black bg-opacity-75 w-full h-full">
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<div class="w-full h-full flex justify-center">
|
||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" ref="cover" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
|
||||
</div>
|
||||
|
||||
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30">
|
||||
@@ -39,14 +41,14 @@
|
||||
</div>
|
||||
|
||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
||||
<p class="title-text font-book truncate">{{ title }}</p>
|
||||
<p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p>
|
||||
<p class="title-text truncate">{{ title }}</p>
|
||||
<p class="author-text text-white text-opacity-75 truncate">{{ authorName }}</p>
|
||||
</div>
|
||||
|
||||
<div id="playerContent" class="playerContainer w-full z-20 bg-primary absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" @click="clickContainer">
|
||||
<div v-if="showFullscreen" class="absolute top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
|
||||
<div id="playerContent" class="playerContainer w-full z-20 absolute bottom-0 left-0 right-0 p-2 pointer-events-auto transition-all" :style="{ backgroundColor: showFullscreen ? '' : coverRgb }" @click="clickContainer">
|
||||
<div v-if="showFullscreen" class="absolute bottom-4 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
|
||||
<div class="flex items-center justify-between pointer-events-auto">
|
||||
<span v-if="!isPodcast && !isLocalPlayMethod" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<span v-if="!isPodcast && isServerItem && networkConnected" class="material-icons text-3xl text-white text-opacity-75 cursor-pointer" @click="$emit('showBookmarks')">{{ bookmarks.length ? 'bookmark' : 'bookmark_border' }}</span>
|
||||
<!-- hidden for podcasts but still using this as a placeholder -->
|
||||
<span v-else class="material-icons text-3xl text-white text-opacity-0">bookmark</span>
|
||||
|
||||
@@ -61,13 +63,16 @@
|
||||
<span class="material-icons text-3xl text-white cursor-pointer" :class="chapters.length ? 'text-opacity-75' : 'text-opacity-10'" @click="showChapterModal = true">format_list_bulleted</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="w-full h-full absolute top-0 left-0 pointer-events-none" style="background: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%)" />
|
||||
|
||||
<div id="playerControls" class="absolute right-0 bottom-0 py-2">
|
||||
<div class="flex items-center justify-center">
|
||||
<span v-show="showFullscreen && !lockUi" class="material-icons next-icon text-white text-opacity-75 cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpChapterStart">first_page</span>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpBackwards">{{ jumpBackwardsIcon }}</span>
|
||||
<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>
|
||||
<div class="play-btn cursor-pointer shadow-sm flex items-center justify-center rounded-full text-primary mx-4 relative overflow-hidden" :style="{ backgroundColor: coverRgb }" :class="{ 'animate-spin': seekLoading }" @mousedown.prevent @mouseup.prevent @click.stop="playPauseClick">
|
||||
<div v-if="!coverBgIsLight" class="absolute top-0 left-0 w-full h-full bg-white bg-opacity-20 pointer-events-none" />
|
||||
|
||||
<span v-if="!isLoading" class="material-icons" :class="{ 'text-white': coverRgb && !coverBgIsLight }">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
||||
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||
</div>
|
||||
<span v-show="!lockUi" class="material-icons jump-icon text-white cursor-pointer" :class="isLoading ? 'text-opacity-10' : 'text-opacity-75'" @click.stop="jumpForward">{{ jumpForwardIcon }}</span>
|
||||
@@ -75,49 +80,33 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi }" />
|
||||
</div>
|
||||
<div id="timestamp-row" class="flex pt-0.5">
|
||||
<div id="playerTrack" class="absolute left-0 w-full px-3">
|
||||
<div class="flex pb-0.5">
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||
<div class="flex-grow" />
|
||||
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
|
||||
<div class="flex-grow" />
|
||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||
</div>
|
||||
<div ref="track" class="h-1.5 w-full bg-gray-500 bg-opacity-50 relative" :class="{ 'animate-pulse': isLoading }" @touchstart="touchstartTrack" @click="clickTrack">
|
||||
<div ref="readyTrack" class="h-full bg-gray-600 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="bufferedTrack" class="h-full bg-gray-500 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="playedTrack" class="h-full bg-gray-200 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="draggingTrack" class="h-full bg-warning bg-opacity-25 absolute top-0 left-0 pointer-events-none" />
|
||||
<div ref="trackCursor" class="h-3.5 w-3.5 rounded-full bg-gray-200 absolute -top-1 pointer-events-none" :class="{ 'opacity-0': lockUi || !showFullscreen }" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction">
|
||||
<template v-slot:chapter_track="{ item }">
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
|
||||
<div class="flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
|
||||
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<template v-slot:lock="{ item }">
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickMenuAction(item.value)">
|
||||
<div class="flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-opacity-80" :class="{ 'text-red-500': lockUi, 'text-white': !lockUi }">{{ item.icon }}</span>
|
||||
<span class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</modals-dialog>
|
||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||
import { FastAverageColor } from 'fast-average-color'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -126,11 +115,13 @@ export default {
|
||||
default: () => []
|
||||
},
|
||||
sleepTimerRunning: Boolean,
|
||||
sleepTimeRemaining: Number
|
||||
sleepTimeRemaining: Number,
|
||||
isServerItem: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
windowHeight: 0,
|
||||
windowWidth: 0,
|
||||
playbackSession: null,
|
||||
showChapterModal: false,
|
||||
showFullscreen: false,
|
||||
@@ -156,39 +147,62 @@ export default {
|
||||
touchStartTime: 0,
|
||||
touchEndY: 0,
|
||||
useChapterTrack: false,
|
||||
useTotalTrack: true,
|
||||
lockUi: false,
|
||||
isLoading: false,
|
||||
touchTrackStart: false,
|
||||
dragPercent: 0,
|
||||
syncStatus: 0,
|
||||
showMoreMenuDialog: false
|
||||
showMoreMenuDialog: false,
|
||||
coverRgb: 'rgb(55, 56, 56)',
|
||||
coverBgIsLight: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showFullscreen(val) {
|
||||
this.updateScreenSize()
|
||||
this.$store.commit('setPlayerFullscreen', !!val)
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
this.updateScreenSize()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
menuItems() {
|
||||
var items = [
|
||||
{
|
||||
text: 'Chapter Track',
|
||||
value: 'chapter_track',
|
||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
|
||||
value: 'lock',
|
||||
icon: this.lockUi ? 'lock' : 'lock_open'
|
||||
},
|
||||
{
|
||||
text: 'Close Player',
|
||||
value: 'close',
|
||||
icon: 'close'
|
||||
}
|
||||
]
|
||||
const items = []
|
||||
// TODO: Implement on iOS
|
||||
if (this.$platform !== 'ios' && !this.isPodcast && this.mediaId) {
|
||||
items.push({
|
||||
text: 'History',
|
||||
value: 'history',
|
||||
icon: 'history'
|
||||
})
|
||||
}
|
||||
|
||||
items.push(
|
||||
...[
|
||||
{
|
||||
text: 'Total Track',
|
||||
value: 'total_track',
|
||||
icon: this.useTotalTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: 'Chapter Track',
|
||||
value: 'chapter_track',
|
||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
||||
},
|
||||
{
|
||||
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
|
||||
value: 'lock',
|
||||
icon: this.lockUi ? 'lock' : 'lock_open'
|
||||
},
|
||||
{
|
||||
text: 'Close Player',
|
||||
value: 'close',
|
||||
icon: 'close'
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
return items
|
||||
},
|
||||
@@ -209,14 +223,29 @@ export default {
|
||||
},
|
||||
bookCoverWidth() {
|
||||
if (this.showFullscreen) return this.fullscreenBookCoverWidth
|
||||
return 60
|
||||
return 46 / this.bookCoverAspectRatio
|
||||
},
|
||||
fullscreenBookCoverWidth() {
|
||||
var heightScale = (this.windowHeight - 200) / 651
|
||||
if (this.bookCoverAspectRatio === 1) {
|
||||
return 260 * heightScale
|
||||
if (this.windowWidth < this.windowHeight) {
|
||||
// Portrait
|
||||
let sideSpace = 20
|
||||
if (this.bookCoverAspectRatio === 1.6) sideSpace += (this.windowWidth - sideSpace) * 0.375
|
||||
|
||||
const availableHeight = this.windowHeight - 400
|
||||
let width = this.windowWidth - sideSpace
|
||||
const totalHeight = width * this.bookCoverAspectRatio
|
||||
if (totalHeight > availableHeight) {
|
||||
width = availableHeight / this.bookCoverAspectRatio
|
||||
}
|
||||
return width
|
||||
} else {
|
||||
// Landscape
|
||||
const heightScale = (this.windowHeight - 200) / 651
|
||||
if (this.bookCoverAspectRatio === 1) {
|
||||
return 260 * heightScale
|
||||
}
|
||||
return 190 * heightScale
|
||||
}
|
||||
return 200 * heightScale
|
||||
},
|
||||
showCastBtn() {
|
||||
return this.$store.state.isCastAvailable
|
||||
@@ -324,9 +353,36 @@ export default {
|
||||
} else {
|
||||
return secondsRemaining + 's'
|
||||
}
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
mediaId() {
|
||||
if (this.isPodcast || !this.playbackSession) return null
|
||||
if (this.playbackSession.libraryItemId) {
|
||||
return this.playbackSession.episodeId ? `${this.playbackSession.libraryItemId}-${this.playbackSession.episodeId}` : this.playbackSession.libraryItemId
|
||||
}
|
||||
const localLibraryItem = this.playbackSession.localLibraryItem
|
||||
if (!localLibraryItem) return null
|
||||
|
||||
return this.playbackSession.localEpisodeId ? `${localLibraryItem.id}-${this.playbackSession.localEpisodeId}` : localLibraryItem.id
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async coverImageLoaded(fullCoverUrl) {
|
||||
if (!fullCoverUrl) return
|
||||
|
||||
const fac = new FastAverageColor()
|
||||
fac
|
||||
.getColorAsync(fullCoverUrl)
|
||||
.then((color) => {
|
||||
this.coverRgb = color.rgba
|
||||
this.coverBgIsLight = color.isLight
|
||||
})
|
||||
.catch((e) => {
|
||||
console.log(e)
|
||||
})
|
||||
},
|
||||
clickTitleAndAuthor() {
|
||||
if (!this.showFullscreen) return
|
||||
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
|
||||
@@ -335,15 +391,18 @@ export default {
|
||||
this.showFullscreen = false
|
||||
}
|
||||
},
|
||||
touchstartTrack(e) {
|
||||
async touchstartTrack(e) {
|
||||
await this.$hapticsImpact()
|
||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||
this.touchTrackStart = true
|
||||
},
|
||||
selectChapter(chapter) {
|
||||
async selectChapter(chapter) {
|
||||
await this.$hapticsImpact()
|
||||
this.seek(chapter.start)
|
||||
this.showChapterModal = false
|
||||
},
|
||||
castClick() {
|
||||
async castClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLocalPlayMethod) {
|
||||
this.$eventBus.$emit('cast-local-item')
|
||||
return
|
||||
@@ -362,12 +421,14 @@ export default {
|
||||
this.showFullscreen = false
|
||||
this.forceCloseDropdownMenu()
|
||||
},
|
||||
jumpNextChapter() {
|
||||
async jumpNextChapter() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (!this.nextChapter) return
|
||||
this.seek(this.nextChapter.start)
|
||||
},
|
||||
jumpChapterStart() {
|
||||
async jumpChapterStart() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
if (!this.currentChapter) {
|
||||
return this.restart()
|
||||
@@ -375,9 +436,9 @@ export default {
|
||||
|
||||
// If 4 seconds or less into current chapter, then go to previous
|
||||
if (this.currentTime - this.currentChapter.start <= 4) {
|
||||
var currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime)
|
||||
const currChapterIndex = this.chapters.findIndex((ch) => Number(ch.start) <= this.currentTime && Number(ch.end) >= this.currentTime)
|
||||
if (currChapterIndex > 0) {
|
||||
var prevChapter = this.chapters[currChapterIndex - 1]
|
||||
const prevChapter = this.chapters[currChapterIndex - 1]
|
||||
this.seek(prevChapter.start)
|
||||
}
|
||||
} else {
|
||||
@@ -387,7 +448,8 @@ export default {
|
||||
showSleepTimerModal() {
|
||||
this.$emit('showSleepTimer')
|
||||
},
|
||||
setPlaybackSpeed(speed) {
|
||||
async setPlaybackSpeed(speed) {
|
||||
await this.$hapticsImpact()
|
||||
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
|
||||
this.currentPlaybackRate = speed
|
||||
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
|
||||
@@ -395,11 +457,13 @@ export default {
|
||||
restart() {
|
||||
this.seek(0)
|
||||
},
|
||||
jumpBackwards() {
|
||||
async jumpBackwards() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
||||
},
|
||||
jumpForward() {
|
||||
async jumpForward() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
||||
},
|
||||
@@ -408,19 +472,19 @@ export default {
|
||||
this.updateReadyTrack()
|
||||
},
|
||||
setChunksReady(chunks, numSegments) {
|
||||
var largestSeg = 0
|
||||
let largestSeg = 0
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
var chunk = chunks[i]
|
||||
const chunk = chunks[i]
|
||||
if (typeof chunk === 'string') {
|
||||
var chunkRange = chunk.split('-').map((c) => Number(c))
|
||||
const chunkRange = chunk.split('-').map((c) => Number(c))
|
||||
if (chunkRange.length < 2) continue
|
||||
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
||||
} else if (chunk > largestSeg) {
|
||||
largestSeg = chunk
|
||||
}
|
||||
}
|
||||
var percentageReady = largestSeg / numSegments
|
||||
var widthReady = Math.round(this.trackWidth * percentageReady)
|
||||
const percentageReady = largestSeg / numSegments
|
||||
const widthReady = Math.round(this.trackWidth * percentageReady)
|
||||
if (this.readyTrackWidth === widthReady) {
|
||||
return
|
||||
}
|
||||
@@ -472,17 +536,17 @@ export default {
|
||||
},
|
||||
updateTrack() {
|
||||
// Update progress track UI
|
||||
var percentDone = this.currentTime / this.totalDuration
|
||||
var totalPercentDone = percentDone
|
||||
var bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
var totalBufferedPercent = bufferedPercent
|
||||
let percentDone = this.currentTime / this.totalDuration
|
||||
const totalPercentDone = percentDone
|
||||
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||
const totalBufferedPercent = bufferedPercent
|
||||
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = this.currentTime - this.currentChapter.start
|
||||
const currChapTime = this.currentTime - this.currentChapter.start
|
||||
percentDone = currChapTime / this.currentChapterDuration
|
||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / this.currentChapterDuration))
|
||||
}
|
||||
var ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
const ptWidth = Math.round(percentDone * this.trackWidth)
|
||||
this.$refs.playedTrack.style.width = ptWidth + 'px'
|
||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||
|
||||
@@ -539,6 +603,7 @@ export default {
|
||||
this.seek(time)
|
||||
},
|
||||
async playPauseClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.isLoading) return
|
||||
|
||||
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
||||
@@ -641,19 +706,32 @@ export default {
|
||||
ts.innerText = currTimeStr
|
||||
}
|
||||
},
|
||||
clickMenuAction(action) {
|
||||
async clickMenuAction(action) {
|
||||
await this.$hapticsImpact()
|
||||
this.showMoreMenuDialog = false
|
||||
this.$nextTick(() => {
|
||||
if (action === 'lock') {
|
||||
if (action === 'history') {
|
||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||
this.showFullscreen = false
|
||||
} else if (action === 'lock') {
|
||||
this.lockUi = !this.lockUi
|
||||
this.$localStore.setPlayerLock(this.lockUi)
|
||||
} else if (action === 'chapter_track') {
|
||||
this.useChapterTrack = !this.useChapterTrack
|
||||
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
||||
} else if (action === 'total_track') {
|
||||
this.useTotalTrack = !this.useTotalTrack
|
||||
this.useChapterTrack = !this.useTotalTrack || this.useChapterTrack
|
||||
|
||||
this.updateTimestamp()
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
this.$localStore.setUseTotalTrack(this.useTotalTrack)
|
||||
} else if (action === 'close') {
|
||||
this.closePlayback()
|
||||
}
|
||||
@@ -750,16 +828,27 @@ export default {
|
||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||
},
|
||||
screenOrientationChange() {
|
||||
setTimeout(this.updateScreenSize, 50)
|
||||
setTimeout(() => {
|
||||
this.updateScreenSize()
|
||||
if (this.$refs.track) {
|
||||
this.trackWidth = this.$refs.track.clientWidth
|
||||
this.updateTrack()
|
||||
this.updateReadyTrack()
|
||||
}
|
||||
}, 50)
|
||||
},
|
||||
updateScreenSize() {
|
||||
this.windowHeight = window.innerHeight
|
||||
var coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||
this.windowWidth = window.innerWidth
|
||||
const coverHeight = this.fullscreenBookCoverWidth * this.bookCoverAspectRatio
|
||||
const coverImageWidthCollapsed = 46 / this.bookCoverAspectRatio
|
||||
document.documentElement.style.setProperty('--cover-image-width', this.fullscreenBookCoverWidth + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-width-collapsed', coverImageWidthCollapsed + 'px')
|
||||
document.documentElement.style.setProperty('--cover-image-height-collapsed', 46 + 'px')
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 24 + coverImageWidthCollapsed + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
console.log('Minimize Player Evt')
|
||||
this.showFullscreen = false
|
||||
},
|
||||
showProgressSyncIsFailing() {
|
||||
@@ -821,12 +910,11 @@ export default {
|
||||
:root {
|
||||
--cover-image-width: 0px;
|
||||
--cover-image-height: 0px;
|
||||
--cover-image-width-collapsed: 60px;
|
||||
--cover-image-height-collapsed: 60px;
|
||||
}
|
||||
.bookCoverWrapper {
|
||||
box-shadow: 3px -2px 5px #00000066;
|
||||
--cover-image-width-collapsed: 46px;
|
||||
--cover-image-height-collapsed: 46px;
|
||||
--title-author-left-offset-collapsed: 70px;
|
||||
}
|
||||
|
||||
.playerContainer {
|
||||
height: 100px;
|
||||
}
|
||||
@@ -836,23 +924,22 @@ export default {
|
||||
#playerContent {
|
||||
box-shadow: 0px -8px 8px #11111155;
|
||||
}
|
||||
.fullscreen #playerContent {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
#playerTrack {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: margin;
|
||||
margin-bottom: 4px;
|
||||
bottom: 20px;
|
||||
}
|
||||
.fullscreen #playerTrack {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.ios-player #timestamp-row {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
top: 20px;
|
||||
bottom: unset;
|
||||
}
|
||||
|
||||
.cover-wrapper {
|
||||
bottom: 44px;
|
||||
bottom: 48px;
|
||||
left: 12px;
|
||||
height: var(--cover-image-height-collapsed);
|
||||
width: var(--cover-image-width-collapsed);
|
||||
@@ -873,8 +960,8 @@ export default {
|
||||
transform-origin: left bottom;
|
||||
|
||||
width: 40%;
|
||||
bottom: 50px;
|
||||
left: 80px;
|
||||
bottom: 56px;
|
||||
left: var(--title-author-left-offset-collapsed);
|
||||
text-align: left;
|
||||
}
|
||||
.title-author-texts .title-text {
|
||||
@@ -899,10 +986,10 @@ export default {
|
||||
pointer-events: auto;
|
||||
}
|
||||
.fullscreen .title-author-texts .title-text {
|
||||
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.2rem);
|
||||
font-size: clamp(0.8rem, calc(var(--cover-image-height) / 260 * 20), 1.5rem);
|
||||
}
|
||||
.fullscreen .title-author-texts .author-text {
|
||||
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1rem);
|
||||
font-size: clamp(0.6rem, calc(var(--cover-image-height) / 260 * 16), 1.1rem);
|
||||
}
|
||||
|
||||
#playerControls {
|
||||
@@ -912,7 +999,7 @@ export default {
|
||||
width: 140px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
bottom: 45px;
|
||||
bottom: 50px;
|
||||
}
|
||||
#playerControls .jump-icon {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
@@ -949,7 +1036,7 @@ export default {
|
||||
|
||||
.fullscreen #playerControls {
|
||||
width: 100%;
|
||||
bottom: 100px;
|
||||
bottom: 94px;
|
||||
}
|
||||
.fullscreen #playerControls .jump-icon {
|
||||
margin: 0px 18px;
|
||||
@@ -970,4 +1057,4 @@ export default {
|
||||
.fullscreen #playerControls .play-btn .material-icons {
|
||||
font-size: 2.1rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div>
|
||||
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :is-server-item="!!serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||
|
||||
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
||||
@@ -38,21 +38,10 @@ export default {
|
||||
serverEpisodeId: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
socketConnected(newVal) {
|
||||
if (newVal) {
|
||||
console.log('Socket Connected set listeners')
|
||||
this.setListeners()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookmarks() {
|
||||
if (!this.serverLibraryItemId) return []
|
||||
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
|
||||
},
|
||||
socketConnected() {
|
||||
return this.$store.state.socketConnected
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -62,7 +51,7 @@ export default {
|
||||
selectBookmark(bookmark) {
|
||||
this.showBookmarksModal = false
|
||||
if (!bookmark || isNaN(bookmark.time)) return
|
||||
var bookmarkTime = Number(bookmark.time)
|
||||
const bookmarkTime = Number(bookmark.time)
|
||||
if (this.$refs.audioPlayer) {
|
||||
this.$refs.audioPlayer.seek(bookmarkTime)
|
||||
}
|
||||
@@ -71,8 +60,6 @@ export default {
|
||||
this.isSleepTimerRunning = false
|
||||
if (currentPosition) {
|
||||
console.log('Sleep Timer Ended Current Position: ' + currentPosition)
|
||||
var currentTime = Math.floor(currentPosition / 1000)
|
||||
// TODO: Was syncing to the server here before
|
||||
}
|
||||
},
|
||||
onSleepTimerSet({ value: sleepTimeRemaining }) {
|
||||
@@ -160,17 +147,6 @@ export default {
|
||||
this.notifyOnReady()
|
||||
}
|
||||
},
|
||||
setListeners() {
|
||||
// if (!this.$server.socket) {
|
||||
// console.error('Invalid server socket not set')
|
||||
// return
|
||||
// }
|
||||
// this.$server.socket.on('stream_open', this.streamOpen)
|
||||
// this.$server.socket.on('stream_closed', this.streamClosed)
|
||||
// this.$server.socket.on('stream_progress', this.streamProgress)
|
||||
// this.$server.socket.on('stream_ready', this.streamReady)
|
||||
// this.$server.socket.on('stream_reset', this.streamReset)
|
||||
},
|
||||
closeStreamOnly() {
|
||||
// If user logs out or disconnects from server and not playing local
|
||||
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||
@@ -206,13 +182,14 @@ export default {
|
||||
})
|
||||
},
|
||||
async playLibraryItem(payload) {
|
||||
var libraryItemId = payload.libraryItemId
|
||||
var episodeId = payload.episodeId
|
||||
const libraryItemId = payload.libraryItemId
|
||||
const episodeId = payload.episodeId
|
||||
const startTime = payload.startTime
|
||||
|
||||
// When playing local library item and can also play this item from the server
|
||||
// then store the server library item id so it can be used if a cast is made
|
||||
var serverLibraryItemId = payload.serverLibraryItemId || null
|
||||
var serverEpisodeId = payload.serverEpisodeId || null
|
||||
const serverLibraryItemId = payload.serverLibraryItemId || null
|
||||
const serverEpisodeId = payload.serverEpisodeId || null
|
||||
|
||||
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
||||
const { value } = await Dialog.confirm({
|
||||
@@ -224,16 +201,28 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
// if already playing this item then jump to start time
|
||||
if (this.$store.getters['getIsMediaStreaming'](libraryItemId, episodeId)) {
|
||||
console.log('Already streaming item', startTime)
|
||||
if (startTime !== undefined && startTime !== null) {
|
||||
// seek to start time
|
||||
AbsAudioPlayer.seek({ value: Math.floor(startTime) })
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
this.serverLibraryItemId = null
|
||||
this.serverEpisodeId = null
|
||||
|
||||
var playbackRate = 1
|
||||
let playbackRate = 1
|
||||
if (this.$refs.audioPlayer) {
|
||||
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
|
||||
}
|
||||
|
||||
console.log('Called playLibraryItem', libraryItemId)
|
||||
AbsAudioPlayer.prepareLibraryItem({ libraryItemId, episodeId, playWhenReady: true, playbackRate })
|
||||
const preparePayload = { libraryItemId, episodeId, playWhenReady: true, playbackRate }
|
||||
if (startTime !== undefined && startTime !== null) preparePayload.startTime = startTime
|
||||
AbsAudioPlayer.prepareLibraryItem(preparePayload)
|
||||
.then((data) => {
|
||||
if (data.error) {
|
||||
const errorMsg = data.error || 'Failed to play'
|
||||
@@ -278,7 +267,7 @@ export default {
|
||||
notifyOnReady() {
|
||||
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
|
||||
if ( this.settingsLoaded && this.isReady ) {
|
||||
if (this.settingsLoaded && this.isReady) {
|
||||
AbsAudioPlayer.onReady()
|
||||
}
|
||||
}
|
||||
@@ -292,13 +281,12 @@ export default {
|
||||
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||
|
||||
this.setListeners()
|
||||
this.$eventBus.$on('abs-ui-ready', this.onReady)
|
||||
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 })
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
||||
@@ -306,19 +294,12 @@ export default {
|
||||
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
|
||||
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
|
||||
|
||||
// if (this.$server.socket) {
|
||||
// this.$server.socket.off('stream_open', this.streamOpen)
|
||||
// this.$server.socket.off('stream_closed', this.streamClosed)
|
||||
// this.$server.socket.off('stream_progress', this.streamProgress)
|
||||
// this.$server.socket.off('stream_ready', this.streamReady)
|
||||
// this.$server.socket.off('stream_reset', this.streamReset)
|
||||
// }
|
||||
this.$eventBus.$off('abs-ui-ready', this.onReady)
|
||||
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')
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -115,10 +115,10 @@ export default {
|
||||
})
|
||||
} else {
|
||||
items.push({
|
||||
icon: 'download',
|
||||
iconOutlined: false,
|
||||
text: 'Downloads',
|
||||
to: '/downloads'
|
||||
icon: 'download',
|
||||
iconOutlined: false,
|
||||
text: 'Downloads',
|
||||
to: '/downloads'
|
||||
})
|
||||
}
|
||||
items.push({
|
||||
@@ -137,6 +137,7 @@ export default {
|
||||
this.show = false
|
||||
},
|
||||
async logout() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.user) {
|
||||
await this.$axios.$post('/logout').catch((error) => {
|
||||
console.error(error)
|
||||
|
||||
@@ -95,16 +95,16 @@ export default {
|
||||
var coverSize = 100
|
||||
if (window.innerWidth <= 375) coverSize = 90
|
||||
|
||||
if (this.isCoverSquareAspectRatio) return coverSize * 1.6
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return coverSize * 1.6
|
||||
return coverSize
|
||||
},
|
||||
bookHeight() {
|
||||
if (this.isCoverSquareAspectRatio) return this.bookWidth
|
||||
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
|
||||
return this.bookWidth * 1.6
|
||||
},
|
||||
entityWidth() {
|
||||
if (this.showBookshelfListView) return this.bookshelfWidth - 16
|
||||
if (this.isBookEntity) return this.bookWidth
|
||||
if (this.isBookEntity || this.entityName === 'playlists') return this.bookWidth
|
||||
return this.bookWidth * 2
|
||||
},
|
||||
entityHeight() {
|
||||
@@ -134,7 +134,7 @@ export default {
|
||||
return this.$store.getters['getAltViewEnabled']
|
||||
},
|
||||
sizeMultiplier() {
|
||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||
return this.entityWidth / baseSize
|
||||
}
|
||||
},
|
||||
@@ -145,7 +145,7 @@ export default {
|
||||
})
|
||||
},
|
||||
async fetchEntities(page) {
|
||||
var startIndex = page * this.booksPerFetch
|
||||
const startIndex = page * this.booksPerFetch
|
||||
|
||||
this.isFetchingEntities = true
|
||||
|
||||
@@ -153,11 +153,11 @@ export default {
|
||||
this.currentSFQueryString = this.buildSearchParams()
|
||||
}
|
||||
|
||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
||||
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||
const fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
||||
|
||||
var payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
const payload = await this.$axios.$get(`/api/libraries/${this.currentLibraryId}/${entityPath}${fullQueryString}`).catch((error) => {
|
||||
console.error('failed to fetch books', error)
|
||||
return null
|
||||
})
|
||||
@@ -179,13 +179,13 @@ export default {
|
||||
}
|
||||
|
||||
for (let i = 0; i < payload.results.length; i++) {
|
||||
var index = i + startIndex
|
||||
const index = i + startIndex
|
||||
this.entities[index] = payload.results[i]
|
||||
if (this.entityComponentRefs[index]) {
|
||||
this.entityComponentRefs[index].setEntity(this.entities[index])
|
||||
|
||||
if (this.isBookEntity) {
|
||||
var localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == this.entities[index].id)
|
||||
const localLibraryItem = this.localLibraryItems.find((lli) => lli.libraryItemId == this.entities[index].id)
|
||||
if (localLibraryItem) {
|
||||
this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem)
|
||||
}
|
||||
@@ -309,7 +309,7 @@ export default {
|
||||
var { clientHeight, clientWidth } = bookshelf
|
||||
this.bookshelfHeight = clientHeight
|
||||
this.bookshelfWidth = clientWidth
|
||||
this.entitiesPerShelf = this.showBookshelfListView ? 1 : Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth)
|
||||
this.entitiesPerShelf = Math.max(1, this.showBookshelfListView ? 1 : Math.floor((this.bookshelfWidth - 16) / this.totalEntityCardWidth))
|
||||
this.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
|
||||
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
|
||||
|
||||
@@ -356,8 +356,6 @@ export default {
|
||||
let searchParams = new URLSearchParams()
|
||||
if (this.page === 'series-books') {
|
||||
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||
searchParams.set('sort', 'book.volumeNumber')
|
||||
searchParams.set('desc', 0)
|
||||
} else {
|
||||
if (this.filterBy && this.filterBy !== 'all') {
|
||||
searchParams.set('filter', this.filterBy)
|
||||
@@ -373,15 +371,16 @@ export default {
|
||||
return searchParams.toString()
|
||||
},
|
||||
checkUpdateSearchParams() {
|
||||
var newSearchParams = this.buildSearchParams()
|
||||
var currentQueryString = window.location.search
|
||||
const newSearchParams = this.buildSearchParams()
|
||||
let currentQueryString = window.location.search
|
||||
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
|
||||
|
||||
if (newSearchParams === '') {
|
||||
if (newSearchParams === '' && !currentQueryString) {
|
||||
return false
|
||||
}
|
||||
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + '?' + newSearchParams
|
||||
const queryString = newSearchParams ? `?${newSearchParams}` : ''
|
||||
let newurl = window.location.protocol + '//' + window.location.host + window.location.pathname + queryString
|
||||
window.history.replaceState({ path: newurl }, '', newurl)
|
||||
|
||||
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
|
||||
@@ -391,12 +390,17 @@ export default {
|
||||
return false
|
||||
},
|
||||
settingsUpdated(settings) {
|
||||
var wasUpdated = this.checkUpdateSearchParams()
|
||||
const wasUpdated = this.checkUpdateSearchParams()
|
||||
if (wasUpdated) {
|
||||
this.resetEntities()
|
||||
}
|
||||
},
|
||||
libraryChanged() {
|
||||
if (this.currentLibraryMediaType !== 'book' && (this.page === 'series' || this.page === 'collections' || this.page === 'series-books')) {
|
||||
this.$router.replace('/bookshelf')
|
||||
return
|
||||
}
|
||||
|
||||
if (this.hasFilter) {
|
||||
this.clearFilter()
|
||||
} else {
|
||||
@@ -449,13 +453,13 @@ export default {
|
||||
})
|
||||
},
|
||||
initListeners() {
|
||||
var bookshelf = document.getElementById('bookshelf-wrapper')
|
||||
const bookshelf = document.getElementById('bookshelf-wrapper')
|
||||
if (bookshelf) {
|
||||
bookshelf.addEventListener('scroll', this.scroll)
|
||||
}
|
||||
|
||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'lazy-bookshelf', meth: this.settingsUpdated })
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
|
||||
this.$socket.$on('item_updated', this.libraryItemUpdated)
|
||||
this.$socket.$on('item_added', this.libraryItemAdded)
|
||||
@@ -464,13 +468,13 @@ export default {
|
||||
this.$socket.$on('items_added', this.libraryItemsAdded)
|
||||
},
|
||||
removeListeners() {
|
||||
var bookshelf = document.getElementById('bookshelf-wrapper')
|
||||
const bookshelf = document.getElementById('bookshelf-wrapper')
|
||||
if (bookshelf) {
|
||||
bookshelf.removeEventListener('scroll', this.scroll)
|
||||
}
|
||||
|
||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
||||
this.$store.commit('user/removeSettingsListener', 'lazy-bookshelf')
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
|
||||
this.$socket.$off('item_updated', this.libraryItemUpdated)
|
||||
this.$socket.$off('item_added', this.libraryItemAdded)
|
||||
|
||||
@@ -319,9 +319,6 @@ export default {
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
_socket() {
|
||||
return this.$root.socket || this.$nuxt.$root.socket
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
@@ -404,12 +401,13 @@ export default {
|
||||
// Server books may have a local library item
|
||||
this.localLibraryItem = localLibraryItem
|
||||
},
|
||||
clickCard(e) {
|
||||
async clickCard(e) {
|
||||
if (this.isSelectionMode) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.selectBtnClick()
|
||||
} else if (this.recentEpisode) {
|
||||
await this.$hapticsImpact()
|
||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||
if (this.streamIsPlaying) {
|
||||
eventBus.$emit('pause-item')
|
||||
@@ -528,9 +526,9 @@ export default {
|
||||
this.imageReady = true
|
||||
|
||||
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
|
||||
const { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
const aspectRatio = naturalHeight / naturalWidth
|
||||
const arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
|
||||
@@ -63,9 +63,6 @@ export default {
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
router.push(`/collection/${this.collection.id}`)
|
||||
},
|
||||
clickEdit() {
|
||||
this.$emit('edit', this.collection)
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<div ref="card" :id="`book-card-${index}`" :style="{ minWidth: width + 'px', maxWidth: width + 'px', height: height + 'px' }" class="rounded-sm z-10 cursor-pointer py-1" @click="clickCard">
|
||||
<div class="h-full flex relative">
|
||||
<div class="w-20 h-20 relative" style="min-width: 80px; max-width: 80px">
|
||||
<div class="list-card-cover relative">
|
||||
<!-- When cover image does not fill -->
|
||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 bg-primary">
|
||||
<div class="w-full h-full absolute top-0 left-0">
|
||||
<img v-show="libraryItem" ref="cover" :src="bookCoverSrc" class="w-full h-full transition-opacity duration-300" :class="showCoverBg ? 'object-contain' : 'object-fill'" @load="imageLoaded" :style="{ opacity: imageReady ? 1 : 0 }" />
|
||||
</div>
|
||||
|
||||
<!-- No progress shown for collapsed series or podcasts in library -->
|
||||
<div v-if="!isPodcast && !collapsedSeries" 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: 80 * userProgressPercent + 'px' }"></div>
|
||||
<div v-if="!isPodcast && !collapsedSeries" 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: coverWidth * userProgressPercent + 'px' }"></div>
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||
@@ -76,6 +76,9 @@ export default {
|
||||
this.libraryItem = newVal
|
||||
}
|
||||
}
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
this.setCSSProperties()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
@@ -278,9 +281,6 @@ export default {
|
||||
userIsRoot() {
|
||||
return this.store.getters['user/getIsRoot']
|
||||
},
|
||||
_socket() {
|
||||
return this.$root.socket || this.$nuxt.$root.socket
|
||||
},
|
||||
titleFontSize() {
|
||||
return 0.75 * this.sizeMultiplier
|
||||
},
|
||||
@@ -316,6 +316,9 @@ export default {
|
||||
if (!this.isAlternativeBookshelfView) return 0
|
||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||
return 4.25 * this.sizeMultiplier
|
||||
},
|
||||
coverWidth() {
|
||||
return 80 / this.bookCoverAspectRatio
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -436,9 +439,9 @@ export default {
|
||||
this.imageReady = true
|
||||
|
||||
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
|
||||
const { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
const aspectRatio = naturalHeight / naturalWidth
|
||||
const arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
@@ -448,9 +451,13 @@ export default {
|
||||
this.showCoverBg = false
|
||||
}
|
||||
}
|
||||
},
|
||||
setCSSProperties() {
|
||||
document.documentElement.style.setProperty('--list-card-cover-width', this.coverWidth + 'px')
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.setCSSProperties()
|
||||
if (this.bookMount) {
|
||||
this.setEntity(this.bookMount)
|
||||
|
||||
@@ -461,3 +468,17 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--list-card-cover-width: 80px;
|
||||
}
|
||||
|
||||
.list-card-cover {
|
||||
height: 80px;
|
||||
max-height: 80px;
|
||||
width: var(--list-card-cover-width);
|
||||
min-width: var(--list-card-cover-width);
|
||||
max-width: var(--list-card-cover-width);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,86 @@
|
||||
<template>
|
||||
<div ref="card" :id="`playlist-card-${index}`" :style="{ width: width + 'px', height: height + 'px' }" class="absolute top-0 left-0 rounded-sm z-30 cursor-pointer" @click="clickCard">
|
||||
<div class="absolute top-0 left-0 w-full box-shadow-book shadow-height" />
|
||||
<div class="w-full h-full bg-primary relative rounded overflow-hidden">
|
||||
<covers-playlist-cover ref="cover" :items="items" :width="width" :height="height" />
|
||||
</div>
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-md font-book text-center" :style="{ width: Math.min(160, width) + 'px' }">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border" :class="isAltViewEnabled ? 'altBookshelfLabel' : 'shinyBlack'" :style="{ padding: `0rem ${0.5 * sizeMultiplier}rem` }">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
index: Number,
|
||||
width: Number,
|
||||
height: Number,
|
||||
bookCoverAspectRatio: Number,
|
||||
playlistMount: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
isAltViewEnabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
playlist: null,
|
||||
isSelectionMode: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
labelFontSize() {
|
||||
if (this.width < 160) return 0.75
|
||||
return 0.875
|
||||
},
|
||||
sizeMultiplier() {
|
||||
if (this.bookCoverAspectRatio === 1) return this.width / (120 * 1.6 * 2)
|
||||
return this.width / 240
|
||||
},
|
||||
title() {
|
||||
return this.playlist ? this.playlist.name : ''
|
||||
},
|
||||
items() {
|
||||
return this.playlist ? this.playlist.items || [] : []
|
||||
},
|
||||
store() {
|
||||
return this.$store || this.$nuxt.$store
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.store.state.libraries.currentLibraryId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setEntity(playlist) {
|
||||
this.playlist = playlist
|
||||
},
|
||||
setSelectionMode(val) {
|
||||
this.isSelectionMode = val
|
||||
},
|
||||
clickCard() {
|
||||
if (!this.playlist) return
|
||||
var router = this.$router || this.$nuxt.$router
|
||||
router.push(`/playlist/${this.playlist.id}`)
|
||||
},
|
||||
destroy() {
|
||||
// destroy the vue listeners, etc
|
||||
this.$destroy()
|
||||
|
||||
// remove the element from the DOM
|
||||
if (this.$el && this.$el.parentNode) {
|
||||
this.$el.parentNode.removeChild(this.$el)
|
||||
} else if (this.$el && this.$el.remove) {
|
||||
this.$el.remove()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.playlistMount) {
|
||||
this.setEntity(this.playlistMount)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -61,10 +61,6 @@ export default {
|
||||
},
|
||||
seriesId() {
|
||||
return this.series ? this.series.id : null
|
||||
},
|
||||
hasValidCovers() {
|
||||
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
|
||||
return !!validCovers.length
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -132,6 +132,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async connectToServer(config) {
|
||||
await this.$hapticsImpact()
|
||||
console.log('[ServerConnectForm] connectToServer', config.address)
|
||||
this.processing = true
|
||||
this.serverConfig = {
|
||||
@@ -159,6 +160,7 @@ export default {
|
||||
},
|
||||
async removeServerConfigClick() {
|
||||
if (!this.serverConfig.id) return
|
||||
await this.$hapticsImpact()
|
||||
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
@@ -189,7 +191,8 @@ export default {
|
||||
this.showForm = true
|
||||
this.showAuth = true
|
||||
},
|
||||
newServerConfigClick() {
|
||||
async newServerConfigClick() {
|
||||
await this.$hapticsImpact()
|
||||
this.serverConfig = {
|
||||
address: '',
|
||||
userId: '',
|
||||
@@ -348,4 +351,4 @@ export default {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -47,7 +47,8 @@ export default {
|
||||
default: 120
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
downloadCover: String
|
||||
downloadCover: String,
|
||||
raw: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -115,7 +116,7 @@ export default {
|
||||
if (this.downloadCover) return this.downloadCover
|
||||
if (!this.libraryItem) return null
|
||||
var store = this.$store || this.$nuxt.$store
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl)
|
||||
return store.getters['globals/getLibraryItemCoverSrc'](this.libraryItem, this.placeholderUrl, this.raw)
|
||||
},
|
||||
cover() {
|
||||
return this.media.coverPath || this.placeholderUrl
|
||||
@@ -168,6 +169,8 @@ export default {
|
||||
this.showCoverBg = false
|
||||
}
|
||||
}
|
||||
|
||||
this.$emit('imageLoaded', this.fullCoverUrl)
|
||||
},
|
||||
imageError(err) {
|
||||
this.loading = false
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ width: width + 'px', height: height + 'px' }">
|
||||
<div v-if="items.length" class="flex flex-wrap justify-center h-full relative bg-primary bg-opacity-95 rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
<covers-book-cover v-for="(li, index) in libraryItemCovers" :key="index" :library-item="li" :width="itemCoverWidth" :book-cover-aspect-ratio="1" />
|
||||
</div>
|
||||
<div v-else class="relative w-full h-full flex items-center justify-center p-2 bg-primary rounded-sm">
|
||||
<div class="absolute top-0 left-0 w-full h-full bg-gray-400 bg-opacity-5" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
width: Number,
|
||||
height: Number
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
sizeMultiplier() {
|
||||
return this.width / (120 * 1.6 * 2)
|
||||
},
|
||||
itemCoverWidth() {
|
||||
if (this.libraryItemCovers.length === 1) return this.width
|
||||
return this.width / 2
|
||||
},
|
||||
libraryItemCovers() {
|
||||
if (!this.items.length) return []
|
||||
if (this.items.length === 1) return [this.items[0].libraryItem]
|
||||
|
||||
const covers = []
|
||||
for (let i = 0; i < 4; i++) {
|
||||
let index = i % this.items.length
|
||||
if (this.items.length === 2 && i >= 2) index = (i + 1) % 2 // for playlists with 2 items show covers in checker pattern
|
||||
|
||||
covers.push(this.items[index].libraryItem)
|
||||
}
|
||||
return covers
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm" :style="{ height: width * bookCoverAspectRatio + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }" @mouseover="isHovering = true" @mouseleave="isHovering = false">
|
||||
<div class="w-full h-full relative overflow-hidden">
|
||||
<div v-show="showCoverBg" class="absolute top-0 left-0 w-full h-full overflow-hidden rounded-sm bg-primary">
|
||||
<div class="absolute cover-bg" ref="coverBg" />
|
||||
</div>
|
||||
<img ref="cover" :src="cover" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0" :class="showCoverBg ? 'object-contain' : 'object-fill'" />
|
||||
|
||||
<a v-if="!imageFailed && showOpenNewTab && isHovering" :href="cover" @click.stop target="_blank" class="absolute bg-primary flex items-center justify-center shadow-sm rounded-full hover:scale-110 transform duration-100" :style="{ top: sizeMultiplier * 0.5 + 'rem', right: sizeMultiplier * 0.5 + 'rem', width: 2.5 * sizeMultiplier + 'rem', height: 2.5 * sizeMultiplier + 'rem' }">
|
||||
<span class="material-icons" :style="{ fontSize: sizeMultiplier * 1.75 + 'rem' }">open_in_new</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div v-if="imageFailed" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full bg-red-100" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div class="w-full h-full border-2 border-error flex flex-col items-center justify-center">
|
||||
<img src="/Logo.png" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="!imageFailed && showResolution" class="absolute -bottom-5 left-0 right-0 mx-auto text-xs text-gray-300 text-center">{{ resolution }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
src: String,
|
||||
width: {
|
||||
type: Number,
|
||||
default: 120
|
||||
},
|
||||
showOpenNewTab: Boolean,
|
||||
bookCoverAspectRatio: Number,
|
||||
showResolution: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
imageFailed: false,
|
||||
showCoverBg: false,
|
||||
isHovering: false,
|
||||
naturalHeight: 0,
|
||||
naturalWidth: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
cover() {
|
||||
this.imageFailed = false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
cover() {
|
||||
return this.src
|
||||
},
|
||||
sizeMultiplier() {
|
||||
return this.width / 120
|
||||
},
|
||||
placeholderCoverPadding() {
|
||||
return 0.8 * this.sizeMultiplier
|
||||
},
|
||||
resolution() {
|
||||
return `${this.naturalWidth}x${this.naturalHeight}px`
|
||||
},
|
||||
placeholderUrl() {
|
||||
const config = this.$config || this.$nuxt.$config
|
||||
return `${config.routerBasePath}/book_placeholder.jpg`
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setCoverBg() {
|
||||
if (this.$refs.coverBg) {
|
||||
this.$refs.coverBg.style.backgroundImage = `url("${this.src}")`
|
||||
}
|
||||
},
|
||||
imageLoaded() {
|
||||
if (this.$refs.cover && this.src !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
this.naturalHeight = naturalHeight
|
||||
this.naturalWidth = naturalWidth
|
||||
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
|
||||
|
||||
// If image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||
if (arDiff > 0.15) {
|
||||
this.showCoverBg = true
|
||||
this.$nextTick(this.setCoverBg)
|
||||
} else {
|
||||
this.showCoverBg = false
|
||||
}
|
||||
}
|
||||
},
|
||||
imageError(err) {
|
||||
console.error('ImgError', err)
|
||||
this.imageFailed = true
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="w-full px-2">
|
||||
<img v-if="podcast.imageUrl" :src="podcast.imageUrl" class="h-36 w-36 object-contain mx-auto mb-2" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.title" :label="'Title'" class="mb-2 text-sm" @input="titleUpdated" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.author" :label="'Author'" class="mb-2 text-sm" />
|
||||
|
||||
<ui-text-input-with-label v-model="podcast.feedUrl" :label="'Feed URL'" readonly class="mb-2 text-sm" />
|
||||
|
||||
<ui-multi-select v-model="podcast.genres" :items="podcast.genres" :label="'Genres'" class="mb-2 text-sm" />
|
||||
|
||||
<ui-textarea-with-label v-model="podcast.description" :label="'Description'" :rows="3" class="mb-2 text-sm" />
|
||||
|
||||
<ui-dropdown v-model="selectedFolderId" :items="folderItems" :disabled="processing" :label="'Folder'" class="mb-2 text-sm" @input="folderUpdated" />
|
||||
|
||||
<ui-text-input-with-label v-model="fullPath" :label="'Podcast Path'" input-class="h-10" readonly class="mb-2 text-sm" />
|
||||
|
||||
<div class="flex items-center py-4 px-2">
|
||||
<ui-checkbox v-model="podcast.autoDownloadEpisodes" :label="'Auto Download Episodes'" checkbox-bg="primary" border-color="gray-600" label-class="pl-2 text-sm font-semibold" />
|
||||
<div class="flex-grow" />
|
||||
<ui-btn color="success" @click="submit">Submit</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Path from 'path'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
processing: Boolean,
|
||||
podcastData: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
podcastFeedData: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedFolderId: null,
|
||||
fullPath: null,
|
||||
podcast: {
|
||||
title: '',
|
||||
author: '',
|
||||
description: '',
|
||||
releaseDate: '',
|
||||
genres: [],
|
||||
feedUrl: '',
|
||||
feedImageUrl: '',
|
||||
itunesPageUrl: '',
|
||||
itunesId: '',
|
||||
itunesArtistId: '',
|
||||
autoDownloadEpisodes: false
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
_processing: {
|
||||
get() {
|
||||
return this.processing
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:processing', val)
|
||||
}
|
||||
},
|
||||
title() {
|
||||
return this._podcastData.title
|
||||
},
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
folders() {
|
||||
if (!this.currentLibrary) return []
|
||||
return this.currentLibrary.folders || []
|
||||
},
|
||||
folderItems() {
|
||||
return this.folders.map((fold) => {
|
||||
return {
|
||||
value: fold.id,
|
||||
text: fold.fullPath
|
||||
}
|
||||
})
|
||||
},
|
||||
_podcastData() {
|
||||
return this.podcastData || {}
|
||||
},
|
||||
feedMetadata() {
|
||||
if (!this.podcastFeedData) return {}
|
||||
return this.podcastFeedData.metadata || {}
|
||||
},
|
||||
episodes() {
|
||||
if (!this.podcastFeedData) return []
|
||||
return this.podcastFeedData.episodes || []
|
||||
},
|
||||
selectedFolder() {
|
||||
return this.folders.find((f) => f.id === this.selectedFolderId)
|
||||
},
|
||||
selectedFolderPath() {
|
||||
if (!this.selectedFolder) return ''
|
||||
return this.selectedFolder.fullPath
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
titleUpdated() {
|
||||
this.folderUpdated()
|
||||
},
|
||||
folderUpdated() {
|
||||
if (!this.selectedFolderPath || !this.podcast.title) {
|
||||
this.fullPath = ''
|
||||
return
|
||||
}
|
||||
this.fullPath = Path.join(this.selectedFolderPath, this.$sanitizeFilename(this.podcast.title))
|
||||
},
|
||||
submit() {
|
||||
const podcastPayload = {
|
||||
path: this.fullPath,
|
||||
folderId: this.selectedFolderId,
|
||||
libraryId: this.currentLibrary.id,
|
||||
media: {
|
||||
metadata: {
|
||||
title: this.podcast.title,
|
||||
author: this.podcast.author,
|
||||
description: this.podcast.description,
|
||||
releaseDate: this.podcast.releaseDate,
|
||||
genres: [...this.podcast.genres],
|
||||
feedUrl: this.podcast.feedUrl,
|
||||
imageUrl: this.podcast.imageUrl,
|
||||
itunesPageUrl: this.podcast.itunesPageUrl,
|
||||
itunesId: this.podcast.itunesId,
|
||||
itunesArtistId: this.podcast.itunesArtistId,
|
||||
language: this.podcast.language
|
||||
},
|
||||
autoDownloadEpisodes: this.podcast.autoDownloadEpisodes
|
||||
}
|
||||
}
|
||||
console.log('Podcast payload', podcastPayload)
|
||||
|
||||
this._processing = true
|
||||
this.$axios
|
||||
.$post('/api/podcasts', podcastPayload)
|
||||
.then((libraryItem) => {
|
||||
this._processing = false
|
||||
this.$toast.success('Podcast added')
|
||||
this.$router.push(`/item/${libraryItem.id}`)
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to add podcast'
|
||||
console.error('Failed to create podcast', error)
|
||||
this._processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
},
|
||||
init() {
|
||||
// Prefer using itunes podcast data but not always passed in if manually entering rss feed
|
||||
this.podcast.title = this._podcastData.title || this.feedMetadata.title || ''
|
||||
this.podcast.author = this._podcastData.artistName || this.feedMetadata.author || ''
|
||||
this.podcast.description = this._podcastData.description || this.feedMetadata.descriptionPlain || ''
|
||||
this.podcast.releaseDate = this._podcastData.releaseDate || ''
|
||||
this.podcast.genres = this._podcastData.genres || this.feedMetadata.categories || []
|
||||
this.podcast.feedUrl = this._podcastData.feedUrl || this.feedMetadata.feedUrl || ''
|
||||
this.podcast.imageUrl = this._podcastData.cover || this.feedMetadata.image || ''
|
||||
this.podcast.itunesPageUrl = this._podcastData.pageUrl || ''
|
||||
this.podcast.itunesId = this._podcastData.id || ''
|
||||
this.podcast.itunesArtistId = this._podcastData.artistId || ''
|
||||
this.podcast.language = this._podcastData.language || ''
|
||||
this.podcast.autoDownloadEpisodes = false
|
||||
|
||||
if (this.folderItems[0]) {
|
||||
this.selectedFolderId = this.folderItems[0].value
|
||||
this.folderUpdated()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,8 +1,10 @@
|
||||
<template>
|
||||
<div class="w-full h-9 bg-bg relative">
|
||||
<div id="bookshelf-navbar" class="absolute z-10 top-0 left-0 w-full h-full flex bg-secondary text-gray-200">
|
||||
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex items-center justify-center" :style="{ width: isPodcast ? '50%' : '25%' }" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
|
||||
<p>{{ item.text }}</p>
|
||||
<nuxt-link v-for="item in items" :key="item.to" :to="item.to" class="h-full flex-grow flex items-center justify-center" :class="routeName === item.routeName ? 'bg-primary' : 'text-gray-400'">
|
||||
<p v-if="routeName === item.routeName" class="text-sm font-semibold">{{ item.text }}</p>
|
||||
<span v-else-if="item.iconPack === 'abs-icons'" class="abs-icons" :class="`icon-${item.icon} ${item.iconClass || ''}`"></span>
|
||||
<span v-else :class="`${item.iconPack} ${item.iconClass || ''}`">{{ item.icon }}</span>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -14,43 +16,114 @@ export default {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
currentLibrary() {
|
||||
return this.$store.getters['libraries/getCurrentLibrary']
|
||||
},
|
||||
currentLibraryIcon() {
|
||||
return this.currentLibrary ? this.currentLibrary.icon : 'database'
|
||||
},
|
||||
userHasPlaylists() {
|
||||
return this.$store.state.libraries.numUserPlaylists
|
||||
},
|
||||
userIsAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
items() {
|
||||
let items = []
|
||||
if (this.isPodcast) {
|
||||
return [
|
||||
items = [
|
||||
{
|
||||
to: '/bookshelf',
|
||||
routeName: 'bookshelf',
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'home',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Home'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/latest',
|
||||
routeName: 'bookshelf-latest',
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'list',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Latest'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
routeName: 'bookshelf-library',
|
||||
iconPack: 'abs-icons',
|
||||
icon: this.currentLibraryIcon,
|
||||
iconClass: 'text-lg',
|
||||
text: 'Library'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userIsAdminOrUp) {
|
||||
items.push({
|
||||
to: '/bookshelf/search',
|
||||
routeName: 'bookshelf-search',
|
||||
iconPack: 'material-icons',
|
||||
icon: 'podcasts',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Search'
|
||||
})
|
||||
}
|
||||
} else {
|
||||
items = [
|
||||
{
|
||||
to: '/bookshelf',
|
||||
routeName: 'bookshelf',
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'home',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Home'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
routeName: 'bookshelf-library',
|
||||
iconPack: 'abs-icons',
|
||||
icon: this.currentLibraryIcon,
|
||||
iconClass: 'text-lg',
|
||||
text: 'Library'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/series',
|
||||
routeName: 'bookshelf-series',
|
||||
iconPack: 'abs-icons',
|
||||
icon: 'columns',
|
||||
iconClass: 'text-lg pt-px',
|
||||
text: 'Series'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/collections',
|
||||
routeName: 'bookshelf-collections',
|
||||
iconPack: 'material-icons-outlined',
|
||||
icon: 'collections_bookmark',
|
||||
iconClass: 'text-xl',
|
||||
text: 'Collections'
|
||||
}
|
||||
// {
|
||||
// to: '/bookshelf/authors',
|
||||
// routeName: 'bookshelf-authors',
|
||||
// iconPack: 'abs-icons',
|
||||
// icon: 'authors',
|
||||
// iconClass: 'text-2xl pb-px',
|
||||
// text: 'Authors'
|
||||
// }
|
||||
]
|
||||
}
|
||||
return [
|
||||
{
|
||||
to: '/bookshelf',
|
||||
routeName: 'bookshelf',
|
||||
text: 'Home'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/library',
|
||||
routeName: 'bookshelf-library',
|
||||
text: 'Library'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/series',
|
||||
routeName: 'bookshelf-series',
|
||||
text: 'Series'
|
||||
},
|
||||
{
|
||||
to: '/bookshelf/collections',
|
||||
routeName: 'bookshelf-collections',
|
||||
text: 'Collections'
|
||||
}
|
||||
]
|
||||
|
||||
if (this.userHasPlaylists) {
|
||||
items.push({
|
||||
to: '/bookshelf/playlists',
|
||||
routeName: 'bookshelf-playlists',
|
||||
iconPack: 'material-icons',
|
||||
icon: 'queue_music',
|
||||
text: 'Playlists'
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
},
|
||||
routeName() {
|
||||
return this.$route.name
|
||||
@@ -62,7 +135,9 @@ export default {
|
||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
methods: {
|
||||
isSelected(item) {}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
|
||||
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="bookshelfListView = !bookshelfListView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="changeView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<template v-if="page === 'library'">
|
||||
<div class="relative flex items-center px-2">
|
||||
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
||||
@@ -64,6 +64,8 @@ export default {
|
||||
return 'Series'
|
||||
} else if (this.page === 'collections') {
|
||||
return 'Collections'
|
||||
} else if (this.page === 'playlists') {
|
||||
return 'Playlists'
|
||||
}
|
||||
return ''
|
||||
},
|
||||
@@ -85,8 +87,7 @@ export default {
|
||||
this.saveSettings()
|
||||
},
|
||||
saveSettings() {
|
||||
this.$store.commit('user/setSettings', this.settings) // Immediate update
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings) // TODO: No need to update settings on server...
|
||||
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||
},
|
||||
async init() {
|
||||
this.bookshelfListView = await this.$localStore.getBookshelfListView()
|
||||
@@ -100,16 +101,20 @@ export default {
|
||||
},
|
||||
setTotalEntities(total) {
|
||||
this.totalEntities = total
|
||||
},
|
||||
async changeView() {
|
||||
this.bookshelfListView = !this.bookshelfListView
|
||||
await this.$hapticsImpact()
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.init()
|
||||
this.$eventBus.$on('bookshelf-total-entities', this.setTotalEntities)
|
||||
this.$store.commit('user/addSettingsListener', { id: 'bookshelftoolbar', meth: this.settingsUpdated })
|
||||
this.$eventBus.$on('user-settings', this.settingsUpdated)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$eventBus.$off('bookshelf-total-entities', this.setTotalEntities)
|
||||
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
|
||||
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -118,4 +123,4 @@ export default {
|
||||
#bookshelf-toolbar {
|
||||
box-shadow: 0px 5px 5px #11111155;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
@@ -94,6 +95,7 @@ export default {
|
||||
this.showBookmarkTitleInput = true
|
||||
},
|
||||
async deleteBookmark(bm) {
|
||||
await this.$hapticsImpact()
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Remove Bookmark',
|
||||
message: `Are you sure you want to remove bookmark?`
|
||||
@@ -111,7 +113,8 @@ export default {
|
||||
})
|
||||
this.show = false
|
||||
},
|
||||
clickBookmark(bm) {
|
||||
async clickBookmark(bm) {
|
||||
await this.$hapticsImpact()
|
||||
this.$emit('select', bm)
|
||||
},
|
||||
submitUpdateBookmark(updatedBookmark) {
|
||||
@@ -155,7 +158,8 @@ export default {
|
||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||
this.showBookmarkTitleInput = true
|
||||
},
|
||||
submitBookmark() {
|
||||
async submitBookmark() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.selectedBookmark) {
|
||||
var updatePayload = {
|
||||
...this.selectedBookmark,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="300" height="100%">
|
||||
<modals-modal v-model="show" :width="350" height="100%">
|
||||
<template #outer>
|
||||
<div v-if="currentChapter" class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
||||
<p class="text-white text-lg truncate">Current: {{ currentChapterTitle }}</p>
|
||||
@@ -9,12 +9,12 @@
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="chapter in chapters">
|
||||
<template v-for="(chapter, index) in chapters">
|
||||
<li :key="chapter.id" :id="`chapter-row-${chapter.id}`" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="currentChapterId === chapter.id ? 'bg-bg bg-opacity-80' : ''" role="option" @click="clickedOption(chapter)">
|
||||
<div class="relative flex items-center pl-3" style="padding-right: 4.5rem">
|
||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ chapter.title }}</p>
|
||||
<div class="relative flex items-center pl-3 pr-20">
|
||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ index + 1 }} - {{ chapter.title }}</p>
|
||||
<div class="absolute top-0 right-3 -mt-0.5">
|
||||
<span class="font-mono text-white text-opacity-90 leading-3" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span>
|
||||
<span class="font-mono text-white text-opacity-90 leading-3 text-sm" style="letter-spacing: -0.5px">{{ $secondsToTimestamp(chapter.start) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||
<div class="relative flex items-center px-3">
|
||||
<span v-if="item.icon" class="material-icons-outlined text-xl mr-2 text-white text-opacity-80">{{ item.icon }}</span>
|
||||
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
<template #outer>
|
||||
<div v-show="selected !== 'all'" class="absolute top-4 left-4 z-40">
|
||||
<ui-btn class="text-xl border-yellow-400 border-opacity-40" @click="clearSelected">Clear</ui-btn>
|
||||
<!-- <span class="font-semibold uppercase text-white text-2xl">Clear</span> -->
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
@@ -174,7 +173,24 @@ export default {
|
||||
return this.filterData.languages || []
|
||||
},
|
||||
progress() {
|
||||
return ['Finished', 'In Progress', 'Not Started', 'Not Finished']
|
||||
return [
|
||||
{
|
||||
id: 'finished',
|
||||
name: 'Finished'
|
||||
},
|
||||
{
|
||||
id: 'in-progress',
|
||||
name: 'In Progress'
|
||||
},
|
||||
{
|
||||
id: 'not-started',
|
||||
name: 'Not Started'
|
||||
},
|
||||
{
|
||||
id: 'not-finished',
|
||||
name: 'Not Finished'
|
||||
}
|
||||
]
|
||||
},
|
||||
sublistItems() {
|
||||
return (this[this.sublist] || []).map((item) => {
|
||||
@@ -196,7 +212,8 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clearSelected() {
|
||||
async clearSelected() {
|
||||
await this.$hapticsImpact()
|
||||
this.selected = 'all'
|
||||
this.show = false
|
||||
this.$nextTick(() => this.$emit('change', 'all'))
|
||||
@@ -204,7 +221,7 @@ export default {
|
||||
clickedSublistOption(item) {
|
||||
this.clickedOption({ value: `${this.sublist}.${item}` })
|
||||
},
|
||||
clickedOption(option) {
|
||||
async clickedOption(option) {
|
||||
if (option.sublist) {
|
||||
this.sublist = option.value
|
||||
return
|
||||
@@ -215,6 +232,7 @@ export default {
|
||||
this.show = false
|
||||
return
|
||||
}
|
||||
await this.$hapticsImpact()
|
||||
this.selected = val
|
||||
this.show = false
|
||||
this.$nextTick(() => this.$emit('change', val))
|
||||
@@ -228,4 +246,4 @@ export default {
|
||||
.filter-modal-wrapper {
|
||||
max-height: calc(100% - 320px);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="100%" height="100%" max-width="100%">
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<covers-book-cover :library-item="libraryItem" :width="width" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
<covers-book-cover :library-item="libraryItem" :width="width" raw :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
@@ -49,6 +49,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async clickedOption(lib) {
|
||||
await this.$hapticsImpact()
|
||||
this.show = false
|
||||
if (lib.id === this.currentLibraryId) return
|
||||
await this.$store.dispatch('libraries/fetch', lib.id)
|
||||
|
||||
@@ -126,7 +126,8 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedOption(val) {
|
||||
async clickedOption(val) {
|
||||
await this.$hapticsImpact()
|
||||
if (this.selected === val) {
|
||||
this.selectedDesc = !this.selectedDesc
|
||||
} else {
|
||||
@@ -139,4 +140,4 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -43,7 +43,7 @@ export default {
|
||||
return {
|
||||
currentPlaybackRate: 0,
|
||||
MIN_SPEED: 0.5,
|
||||
MAX_SPEED: 3
|
||||
MAX_SPEED: 10
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -71,7 +71,7 @@ export default {
|
||||
}
|
||||
},
|
||||
rates() {
|
||||
return [0.5, 1, 1.2, 1.5, 2]
|
||||
return [0.5, 1, 1.2, 1.5, 2, 3]
|
||||
},
|
||||
canIncrement() {
|
||||
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" width="100%" height="100%" max-width="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-6 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Feed Episodes</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div class="feed-content w-full overflow-x-hidden overflow-y-auto bg-bg rounded-lg border border-white border-opacity-20" @click.stop.prevent>
|
||||
<template v-for="(episode, index) in episodes">
|
||||
<div :key="index" class="relative" :class="itemEpisodeMap[episode.enclosure.url] ? 'bg-primary bg-opacity-40' : selectedEpisodes[String(index)] ? 'bg-success bg-opacity-10' : index % 2 == 0 ? 'bg-primary bg-opacity-25' : 'bg-primary bg-opacity-5'" @click="selectEpisode(episode, index)">
|
||||
<div class="absolute top-0 left-0 h-full flex items-center p-2">
|
||||
<span v-if="itemEpisodeMap[episode.enclosure.url]" class="material-icons text-success text-xl">download_done</span>
|
||||
<ui-checkbox v-else v-model="selectedEpisodes[String(index)]" small checkbox-bg="primary" border-color="gray-600" />
|
||||
</div>
|
||||
<div class="pl-9 pr-2 py-2 border-b border-white border-opacity-10">
|
||||
<p v-if="episode.episode" class="font-semibold text-gray-200 text-xs">#{{ episode.episode }}</p>
|
||||
<p class="break-words mb-1 text-sm">{{ episode.title }}</p>
|
||||
<p v-if="episode.subtitle" class="break-words mb-1 text-xs text-gray-300 episode-subtitle">{{ episode.subtitle }}</p>
|
||||
<p class="text-xxs text-gray-300">Published {{ episode.publishedAt ? $dateDistanceFromNow(episode.publishedAt) : 'Unknown' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div class="absolute bottom-0 left-0 w-full flex items-center" style="height: 50px">
|
||||
<ui-btn class="w-full" :disabled="!episodesSelected.length" color="success" @click.stop="downloadEpisodes">{{ episodesSelected.length ? `Add ${episodesSelected.length} Episode(s) to Server` : 'No Episodes Selected' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
processing: false,
|
||||
selectedEpisodes: {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show: {
|
||||
immediate: true,
|
||||
handler(newVal) {
|
||||
if (newVal) this.init()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
allDownloaded() {
|
||||
return !this.episodes.some((episode) => !this.itemEpisodeMap[episode.enclosure.url])
|
||||
},
|
||||
episodesSelected() {
|
||||
return Object.keys(this.selectedEpisodes).filter((key) => !!this.selectedEpisodes[key])
|
||||
},
|
||||
itemEpisodes() {
|
||||
if (!this.libraryItem) return []
|
||||
return this.libraryItem.media.episodes || []
|
||||
},
|
||||
itemEpisodeMap() {
|
||||
var map = {}
|
||||
this.itemEpisodes.forEach((item) => {
|
||||
if (item.enclosure) map[item.enclosure.url] = true
|
||||
})
|
||||
return map
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
downloadEpisodes() {
|
||||
const episodesToDownload = this.episodesSelected.map((episodeIndex) => this.episodes[Number(episodeIndex)])
|
||||
|
||||
const payloadSize = JSON.stringify(episodesToDownload).length
|
||||
const sizeInMb = payloadSize / 1024 / 1024
|
||||
const sizeInMbPretty = sizeInMb.toFixed(2) + 'MB'
|
||||
console.log('Request size', sizeInMb)
|
||||
if (sizeInMb > 4.99) {
|
||||
return this.$toast.error(`Request is too large (${sizeInMbPretty}) should be < 5Mb`)
|
||||
}
|
||||
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$post(`/api/podcasts/${this.libraryItem.id}/download-episodes`, episodesToDownload)
|
||||
.then(() => {
|
||||
this.processing = false
|
||||
this.$toast.success('Started downloading episodes on server')
|
||||
this.show = false
|
||||
})
|
||||
.catch((error) => {
|
||||
var errorMsg = error.response && error.response.data ? error.response.data : 'Failed to download episodes'
|
||||
console.error('Failed to download episodes', error)
|
||||
this.processing = false
|
||||
this.$toast.error(errorMsg)
|
||||
|
||||
this.selectedEpisodes = {}
|
||||
})
|
||||
},
|
||||
selectEpisode(episode, index) {
|
||||
if (this.itemEpisodeMap[episode.enclosure.url]) return
|
||||
this.selectedEpisodes[String(index)] = !this.selectedEpisodes[String(index)]
|
||||
},
|
||||
init() {
|
||||
this.episodes.sort((a, b) => (a.publishedAt < b.publishedAt ? 1 : -1))
|
||||
for (let i = 0; i < this.episodes.length; i++) {
|
||||
// this.selectedEpisodes[String(i)] = false
|
||||
this.$set(this.selectedEpisodes, String(i), false)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.feed-content {
|
||||
height: calc(100vh - 125px);
|
||||
max-height: calc(100vh - 125px);
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="200" height="100%">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Sleep Timer</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center"
|
||||
@click="
|
||||
show = false
|
||||
manualTimerModal = false
|
||||
"
|
||||
>
|
||||
<div class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||
<div v-if="manualTimerModal" class="p-4">
|
||||
<div class="flex mb-4" @click="manualTimerModal = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<div class="flex my-2 justify-between">
|
||||
<ui-btn @click="decreaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">remove</span></ui-btn>
|
||||
<p class="text-2xl font-mono text-center">{{ manualTimeoutMin }} min</p>
|
||||
<ui-btn @click="increaseManualTimeout" class="w-9 h-9" :padding-x="0" small style="max-width: 36px"><span class="material-icons">add</span></ui-btn>
|
||||
</div>
|
||||
<ui-btn @click="clickedOption(manualTimeoutMin)" class="w-full">Set Timer</ui-btn>
|
||||
</div>
|
||||
<ul v-else class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="timeout in timeouts">
|
||||
<li :key="timeout" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(timeout)">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg">{{ timeout }} min</span>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="clickedChapterOption">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">End of Chapter</span>
|
||||
</div>
|
||||
</li>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">Custom time</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
manualTimerModal: null,
|
||||
manualTimeoutMin: 1
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
timeouts() {
|
||||
return [5, 10, 15, 30, 45, 60, 90]
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clickedChapterOption() {
|
||||
await this.$hapticsImpact()
|
||||
this.show = false
|
||||
this.$nextTick(() => this.$emit('change', 0))
|
||||
},
|
||||
async clickedOption(timeoutMin) {
|
||||
await this.$hapticsImpact()
|
||||
const timeout = timeoutMin * 1000 * 60
|
||||
this.show = false
|
||||
this.manualTimerModal = false
|
||||
this.$nextTick(() => this.$emit('change', timeout))
|
||||
},
|
||||
async increaseManualTimeout() {
|
||||
await this.$hapticsImpact()
|
||||
this.manualTimeoutMin++
|
||||
},
|
||||
async decreaseManualTimeout() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -40,7 +40,7 @@
|
||||
</li>
|
||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal block truncate text-lg text-center">Manual sleep timer</span>
|
||||
<span class="font-normal block truncate text-lg text-center">Custom time</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -89,30 +89,37 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clickedChapterOption() {
|
||||
async clickedChapterOption() {
|
||||
await this.$hapticsImpact()
|
||||
this.show = false
|
||||
this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true }))
|
||||
},
|
||||
clickedOption(timeoutMin) {
|
||||
var timeout = timeoutMin * 1000 * 60
|
||||
async clickedOption(timeoutMin) {
|
||||
await this.$hapticsImpact()
|
||||
const timeout = timeoutMin * 1000 * 60
|
||||
this.show = false
|
||||
this.manualTimerModal = false
|
||||
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
||||
},
|
||||
cancelSleepTimer() {
|
||||
async cancelSleepTimer() {
|
||||
await this.$hapticsImpact()
|
||||
this.$emit('cancel')
|
||||
this.show = false
|
||||
},
|
||||
increaseSleepTime() {
|
||||
async increaseSleepTime() {
|
||||
await this.$hapticsImpact()
|
||||
this.$emit('increase')
|
||||
},
|
||||
decreaseSleepTime() {
|
||||
async decreaseSleepTime() {
|
||||
await this.$hapticsImpact()
|
||||
this.$emit('decrease')
|
||||
},
|
||||
increaseManualTimeout() {
|
||||
async increaseManualTimeout() {
|
||||
await this.$hapticsImpact()
|
||||
this.manualTimeoutMin++
|
||||
},
|
||||
decreaseManualTimeout() {
|
||||
async decreaseManualTimeout() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
<template>
|
||||
<modals-modal v-model="show" :width="360" height="100%" :processing="processing">
|
||||
<template #outer>
|
||||
<div class="absolute top-5 left-4 z-40">
|
||||
<p class="text-white text-2xl truncate">Add to Playlist</p>
|
||||
</div>
|
||||
</template>
|
||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
||||
<div ref="container" class="w-full rounded-lg bg-primary border border-white border-opacity-20 overflow-y-auto overflow-x-hidden" style="max-height: 80vh" @click.stop.prevent>
|
||||
<div class="w-full h-full p-4" v-show="showPlaylistNameInput">
|
||||
<div class="flex mb-4 items-center">
|
||||
<div class="w-9 h-9 flex items-center justify-center rounded-full hover:bg-white hover:bg-opacity-10 cursor-pointer" @click.stop="showPlaylistNameInput = false">
|
||||
<span class="material-icons text-3xl">arrow_back</span>
|
||||
</div>
|
||||
<p class="text-xl pl-2">New Playlist</p>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
|
||||
<ui-text-input-with-label v-model="newPlaylistName" label="Name" />
|
||||
<div class="flex justify-end mt-6">
|
||||
<ui-btn color="success" :loading="processing" class="w-full" @click.stop="submitCreatePlaylist">Create</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full h-full" v-show="!showPlaylistNameInput">
|
||||
<template v-for="playlist in sortedPlaylists">
|
||||
<modals-playlists-playlist-row :key="playlist.id" :in-playlist="playlist.isItemIncluded" :playlist="playlist" @click="clickPlaylist" />
|
||||
</template>
|
||||
<div v-if="!playlists.length" class="flex h-32 items-center justify-center">
|
||||
<p class="text-xl">{{ loading ? 'Loading..' : 'No Playlists' }}</p>
|
||||
</div>
|
||||
<ui-btn :loading="processing" color="success" class="w-full flex items-center justify-center" @click.stop="createPlaylist">
|
||||
<span class="material-icons text-xl">add</span>
|
||||
<p class="text-base pl-2">New Playlist</p>
|
||||
</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</modals-modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showPlaylistNameInput: false,
|
||||
newPlaylistName: '',
|
||||
playlists: [],
|
||||
processing: false,
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
show(newVal) {
|
||||
if (newVal) {
|
||||
this.setListeners()
|
||||
this.showPlaylistNameInput = false
|
||||
this.newPlaylistName = ''
|
||||
this.loadPlaylists()
|
||||
} else {
|
||||
this.unsetListeners()
|
||||
}
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
show: {
|
||||
get() {
|
||||
return this.$store.state.globals.showPlaylistsAddCreateModal
|
||||
},
|
||||
set(val) {
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', val)
|
||||
}
|
||||
},
|
||||
currentLibraryId() {
|
||||
return this.$store.state.libraries.currentLibraryId
|
||||
},
|
||||
selectedPlaylistItems() {
|
||||
return this.$store.state.globals.selectedPlaylistItems || []
|
||||
},
|
||||
sortedPlaylists() {
|
||||
return this.playlists
|
||||
.map((playlist) => {
|
||||
const includesItem = !this.selectedPlaylistItems.some((item) => !this.checkIsItemInPlaylist(playlist, item))
|
||||
|
||||
return {
|
||||
isItemIncluded: includesItem,
|
||||
...playlist
|
||||
}
|
||||
})
|
||||
.sort((a, b) => (a.isItemIncluded ? -1 : 1))
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
checkIsItemInPlaylist(playlist, item) {
|
||||
if (item.episode) {
|
||||
return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id && i.episodeId === item.episode.id)
|
||||
}
|
||||
return playlist.items.some((i) => i.libraryItemId === item.libraryItem.id)
|
||||
},
|
||||
loadPlaylists() {
|
||||
this.loading = true
|
||||
this.$axios
|
||||
.$get(`/api/libraries/${this.currentLibraryId}/playlists`)
|
||||
.then((data) => {
|
||||
this.playlists = data.results || []
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed', error)
|
||||
this.$toast.error('Failed to load playlists')
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false
|
||||
})
|
||||
},
|
||||
async clickPlaylist(playlist) {
|
||||
await this.$hapticsImpact()
|
||||
if (playlist.isItemIncluded) {
|
||||
this.removeFromPlaylist(playlist)
|
||||
} else {
|
||||
this.addToPlaylist(playlist)
|
||||
}
|
||||
},
|
||||
removeFromPlaylist(playlist) {
|
||||
if (!this.selectedPlaylistItems.length) return
|
||||
this.processing = true
|
||||
|
||||
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
||||
this.$axios
|
||||
.$post(`/api/playlists/${playlist.id}/batch/remove`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items removed from playlist`, updatedPlaylist)
|
||||
this.$toast.success('Playlist item(s) removed')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to remove items from playlist', error)
|
||||
this.$toast.error('Failed to remove playlist item(s)')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
addToPlaylist(playlist) {
|
||||
if (!this.selectedPlaylistItems.length) return
|
||||
this.processing = true
|
||||
|
||||
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
||||
this.$axios
|
||||
.$post(`/api/playlists/${playlist.id}/batch/add`, { items: itemObjects })
|
||||
.then((updatedPlaylist) => {
|
||||
console.log(`Items added to playlist`, updatedPlaylist)
|
||||
this.$toast.success('Items added to playlist')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to add items to playlist', error)
|
||||
this.$toast.error('Failed to add items to playlist')
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
createPlaylist() {
|
||||
this.newPlaylistName = ''
|
||||
this.showPlaylistNameInput = true
|
||||
},
|
||||
async submitCreatePlaylist() {
|
||||
await this.$hapticsImpact()
|
||||
if (!this.newPlaylistName || !this.selectedPlaylistItems.length) {
|
||||
return
|
||||
}
|
||||
this.processing = true
|
||||
|
||||
const itemObjects = this.selectedPlaylistItems.map((pi) => ({ libraryItemId: pi.libraryItem.id, episodeId: pi.episode ? pi.episode.id : null }))
|
||||
const newPlaylist = {
|
||||
items: itemObjects,
|
||||
libraryId: this.currentLibraryId,
|
||||
name: this.newPlaylistName
|
||||
}
|
||||
|
||||
this.$axios
|
||||
.$post('/api/playlists', newPlaylist)
|
||||
.then((data) => {
|
||||
console.log('New playlist created', data)
|
||||
this.$toast.success(`Playlist "${data.name}" created`)
|
||||
this.newPlaylistName = ''
|
||||
this.showPlaylistNameInput = false
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to create playlist', error)
|
||||
var errMsg = error.response ? error.response.data || '' : ''
|
||||
this.$toast.error(`Failed to create playlist: ${errMsg}`)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
},
|
||||
playlistAdded(playlist) {
|
||||
if (!this.playlists.some((p) => p.id === playlist.id)) {
|
||||
this.playlists.push(playlist)
|
||||
}
|
||||
},
|
||||
playlistUpdated(playlist) {
|
||||
const index = this.playlists.findIndex((p) => p.id === playlist.id)
|
||||
if (index >= 0) {
|
||||
this.playlists.splice(index, 1, playlist)
|
||||
} else {
|
||||
this.playlists.push(playlist)
|
||||
}
|
||||
},
|
||||
playlistRemoved(playlist) {
|
||||
this.playlists = this.playlists.filter((p) => p.id !== playlist.id)
|
||||
},
|
||||
setListeners() {
|
||||
this.$socket.$on('playlist_added', this.playlistAdded)
|
||||
this.$socket.$on('playlist_updated', this.playlistUpdated)
|
||||
this.$socket.$on('playlist_removed', this.playlistRemoved)
|
||||
},
|
||||
unsetListeners() {
|
||||
this.$socket.$off('playlist_added', this.playlistAdded)
|
||||
this.$socket.$off('playlist_updated', this.playlistUpdated)
|
||||
this.$socket.$off('playlist_removed', this.playlistRemoved)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div :key="playlist.id" :id="`playlist-row-${playlist.id}`" class="flex items-center px-3 py-2 justify-start cursor-pointer relative" :class="inPlaylist ? 'bg-bg bg-opacity-60' : 'bg-opacity-20'">
|
||||
<div v-if="inPlaylist" class="absolute top-0 left-0 h-full w-1 bg-success z-10" />
|
||||
<div class="w-16 max-w-16 text-center">
|
||||
<covers-playlist-cover :items="items" :width="52" :height="52" />
|
||||
</div>
|
||||
<div class="flex-grow overflow-hidden">
|
||||
<p class="pl-1 pr-2 truncate text-sm">{{ playlist.name }}</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-0 right-0 h-full flex items-center px-3" @click.stop="click">
|
||||
<span v-if="inPlaylist" class="material-icons text-error">remove</span>
|
||||
<span v-else class="material-icons text-success">add</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
playlist: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
inPlaylist: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
items() {
|
||||
return this.playlist.items || []
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
click() {
|
||||
this.$emit('click', this.playlist)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="epub-frame" class="w-full">
|
||||
<div id="viewer" class="border border-gray-100 bg-white shadow-md h-full w-full"></div>
|
||||
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center">
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-2 flex items-center" :style="{ bottom: playerLibraryItemId ? '100px' : '0px' }">
|
||||
<p class="text-xs">epub</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
@@ -29,8 +29,25 @@ export default {
|
||||
hasPrev: false
|
||||
}
|
||||
},
|
||||
computed: {},
|
||||
watch: {
|
||||
playerLibraryItemId() {
|
||||
this.updateHeight()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
},
|
||||
readerHeightOffset() {
|
||||
return this.playerLibraryItemId ? 164 : 64
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
updateHeight() {
|
||||
if (this.rendition && this.rendition.resize) {
|
||||
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||
}
|
||||
},
|
||||
prev() {
|
||||
if (this.rendition) {
|
||||
this.rendition.prev()
|
||||
@@ -54,12 +71,12 @@ export default {
|
||||
|
||||
this.rendition = book.renderTo('viewer', {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight - 64,
|
||||
height: window.innerHeight - this.readerHeightOffset,
|
||||
snap: true,
|
||||
manager: 'continuous',
|
||||
flow: 'paginated'
|
||||
})
|
||||
var displayed = this.rendition.display()
|
||||
const displayed = this.rendition.display()
|
||||
|
||||
book.ready
|
||||
.then(() => {
|
||||
@@ -127,4 +144,9 @@ export default {
|
||||
max-height: calc(100% - 32px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.reader-player-open #epub-frame {
|
||||
height: calc(100% - 132px);
|
||||
max-height: calc(100% - 132px);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<div class="ebook-viewer w-full h-full">
|
||||
<div class="absolute overflow-y-scroll left-0 right-0 w-full max-w-screen m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||
<iframe title="html-viewer" class="w-screen"> Loading </iframe>
|
||||
<div class="mobi-ebook-viewer w-full">
|
||||
<div class="absolute overflow-hidden left-0 right-0 w-full max-w-full m-auto z-10 border border-black border-opacity-20 shadow-md bg-white">
|
||||
<iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe>
|
||||
</div>
|
||||
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20">
|
||||
<div class="fixed bottom-0 left-0 h-8 w-full bg-bg px-2 flex items-center z-20" :style="{ bottom: playerLibraryItemId ? '100px' : '0px' }">
|
||||
<p class="text-xs">mobi</p>
|
||||
<div class="flex-grow" />
|
||||
</div>
|
||||
@@ -22,7 +22,14 @@ export default {
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {},
|
||||
computed: {
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
},
|
||||
readerHeightOffset() {
|
||||
return this.playerLibraryItemId ? 164 : 64
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
addHtmlCss() {
|
||||
let iframe = document.getElementsByTagName('iframe')[0]
|
||||
@@ -114,7 +121,14 @@ export default {
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.ebook-viewer {
|
||||
.mobi-ebook-viewer {
|
||||
height: calc(100% - 32px);
|
||||
max-height: calc(100% - 32px);
|
||||
overflow: hidden;
|
||||
}
|
||||
.reader-player-open .mobi-ebook-viewer {
|
||||
height: calc(100% - 132px);
|
||||
max-height: calc(100% - 132px);
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,79 @@
|
||||
<template>
|
||||
<div class="w-full h-full pt-20 relative">
|
||||
<div v-show="canGoPrev" class="absolute top-0 left-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="prev" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2">
|
||||
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_back_ios</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-show="canGoNext" class="absolute top-0 right-0 h-full w-1/2 hover:opacity-100 opacity-0 z-10 cursor-pointer" @click.stop.prevent="next" @mousedown.prevent>
|
||||
<div class="flex items-center justify-center h-full w-1/2 ml-auto">
|
||||
<span class="material-icons text-5xl text-white cursor-pointer text-opacity-30 hover:text-opacity-90">arrow_forward_ios</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="absolute top-0 right-1 bg-bg text-gray-100 border-b border-l border-r border-gray-400 rounded-b-md px-2 h-6 flex items-center text-center">
|
||||
<p class="font-mono text-xs">{{ page }} / {{ numPages }}</p>
|
||||
</div>
|
||||
|
||||
<div :style="{ height: pdfHeight + 'px' }" class="overflow-hidden m-auto">
|
||||
<div class="flex items-center justify-center">
|
||||
<div :style="{ width: pdfWidth + 'px', height: pdfHeight + 'px' }" class="w-full h-full overflow-auto">
|
||||
<div v-if="loadedRatio > 0 && loadedRatio < 1" style="background-color: green; color: white; text-align: center" :style="{ width: loadedRatio * 100 + '%' }">{{ Math.floor(loadedRatio * 100) }}%</div>
|
||||
<pdf ref="pdf" class="m-auto z-10 border border-black border-opacity-20 shadow-md bg-white" :src="url" :page="page" :rotate="rotate" @progress="loadedRatio = $event" @error="error" @num-pages="numPagesLoaded" @link-clicked="page = $event"></pdf>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import pdf from 'vue-pdf'
|
||||
|
||||
export default {
|
||||
components: {
|
||||
pdf
|
||||
},
|
||||
props: {
|
||||
url: String
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
rotate: 0,
|
||||
loadedRatio: 0,
|
||||
page: 1,
|
||||
numPages: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
pdfWidth() {
|
||||
return this.pdfHeight * 0.6667
|
||||
},
|
||||
pdfHeight() {
|
||||
return window.innerHeight - 120
|
||||
},
|
||||
canGoNext() {
|
||||
return this.page < this.numPages
|
||||
},
|
||||
canGoPrev() {
|
||||
return this.page > 1
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
numPagesLoaded(e) {
|
||||
this.numPages = e
|
||||
},
|
||||
prev() {
|
||||
if (this.page <= 1) return
|
||||
this.page--
|
||||
},
|
||||
next() {
|
||||
if (this.page >= this.numPages) return
|
||||
this.page++
|
||||
},
|
||||
error(err) {
|
||||
console.error(err)
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-8">
|
||||
<div v-if="show" class="absolute top-0 left-0 w-full h-full bg-bg z-40 pt-8" :class="{ 'reader-player-open': !!playerLibraryItemId }">
|
||||
<div class="h-8 w-full bg-primary flex items-center px-2 fixed top-0 left-0 z-30 box-shadow-sm">
|
||||
<p class="w-5/6 truncate">{{ title }}</p>
|
||||
<div class="flex-grow" />
|
||||
@@ -14,7 +14,10 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
touchstartX: 0,
|
||||
touchendX: 0
|
||||
touchstartY: 0,
|
||||
touchendX: 0,
|
||||
touchendY: 0,
|
||||
touchstartTime: 0
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -53,6 +56,7 @@ export default {
|
||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
||||
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
||||
return null
|
||||
},
|
||||
folderId() {
|
||||
@@ -89,15 +93,22 @@ export default {
|
||||
},
|
||||
ebookUrl() {
|
||||
if (!this.ebookFile) return null
|
||||
let filepath = ''
|
||||
if (this.selectedLibraryItem.isFile) {
|
||||
filepath = this.$encodeUriPath(this.ebookFile.metadata.filename)
|
||||
} else {
|
||||
const itemRelPath = this.selectedLibraryItem.relPath
|
||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||
const relPath = this.ebookFile.metadata.relPath
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
|
||||
var itemRelPath = this.selectedLibraryItem.relPath
|
||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
||||
|
||||
var relPath = this.ebookFile.metadata.relPath
|
||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
||||
|
||||
var serverAddress = this.$store.getters['user/getServerAddress']
|
||||
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
|
||||
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
|
||||
}
|
||||
const serverAddress = this.$store.getters['user/getServerAddress']
|
||||
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${filepath}`
|
||||
},
|
||||
playerLibraryItemId() {
|
||||
return this.$store.state.playerLibraryItemId
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -112,9 +123,19 @@ export default {
|
||||
}
|
||||
},
|
||||
handleGesture() {
|
||||
// Touch must be less than 1s. Must be > 100px drag and X distance > Y distance
|
||||
const touchTimeMs = Date.now() - this.touchstartTime
|
||||
if (touchTimeMs >= 1000) {
|
||||
console.log('Touch too long', touchTimeMs)
|
||||
return
|
||||
}
|
||||
|
||||
const touchDistanceX = Math.abs(this.touchendX - this.touchstartX)
|
||||
const touchDistanceY = Math.abs(this.touchendY - this.touchstartY)
|
||||
if (touchDistanceX < 100 || touchDistanceY > touchDistanceX) return
|
||||
|
||||
if (this.touchendX < this.touchstartX) {
|
||||
console.log('swiped left')
|
||||
|
||||
this.next()
|
||||
}
|
||||
if (this.touchendX > this.touchstartX) {
|
||||
@@ -124,9 +145,12 @@ export default {
|
||||
},
|
||||
touchstart(e) {
|
||||
this.touchstartX = e.changedTouches[0].screenX
|
||||
this.touchstartY = e.changedTouches[0].screenY
|
||||
this.touchstartTime = Date.now()
|
||||
},
|
||||
touchend(e) {
|
||||
this.touchendX = e.changedTouches[0].screenX
|
||||
this.touchendY = e.changedTouches[0].screenY
|
||||
this.handleGesture()
|
||||
},
|
||||
registerListeners() {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<template>
|
||||
<div class="w-full bg-primary bg-opacity-40">
|
||||
<div class="w-full h-14 flex items-center px-4 bg-primary">
|
||||
<p class="pr-4">Playlist Items</p>
|
||||
|
||||
<div class="w-6 h-6 md:w-7 md:h-7 bg-white bg-opacity-10 rounded-full flex items-center justify-center">
|
||||
<span class="text-xs md:text-sm font-mono leading-none">{{ items.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
<p v-if="totalDuration" class="text-sm text-gray-200">{{ totalDurationPretty }}</p>
|
||||
</div>
|
||||
<template v-for="item in items">
|
||||
<tables-playlist-item-table-row :key="item.id" :item="item" :playlist-id="playlistId" />
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
playlistId: String,
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
totalDuration() {
|
||||
var _total = 0
|
||||
this.items.forEach((item) => {
|
||||
if (item.episode) _total += item.episode.duration
|
||||
else _total += item.libraryItem.media.duration
|
||||
})
|
||||
return _total
|
||||
},
|
||||
totalDurationPretty() {
|
||||
return this.$elapsedPrettyExtended(this.totalDuration)
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,103 @@
|
||||
<template>
|
||||
<div class="w-full px-2 py-2 overflow-hidden relative">
|
||||
<nuxt-link v-if="libraryItem" :to="`/item/${libraryItem.id}`" class="flex w-full">
|
||||
<div class="h-full relative" :style="{ width: '50px' }">
|
||||
<covers-book-cover :library-item="libraryItem" :width="50" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</div>
|
||||
<div class="flex-grow item-table-content h-full px-2 flex items-center">
|
||||
<div class="max-w-full">
|
||||
<p class="truncate block text-sm">{{ itemTitle }}</p>
|
||||
<p class="truncate block text-gray-400 text-xs">{{ bookAuthorName }}</p>
|
||||
<p class="text-xxs text-gray-500">{{ itemDuration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</nuxt-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
playlistId: String,
|
||||
item: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
processingRemove: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
libraryItem() {
|
||||
return this.item.libraryItem || {}
|
||||
},
|
||||
episode() {
|
||||
return this.item.episode
|
||||
},
|
||||
episodeId() {
|
||||
return this.episode ? this.episode.id : null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem.media || {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
tracks() {
|
||||
if (this.episode) return []
|
||||
return this.media.tracks || []
|
||||
},
|
||||
itemTitle() {
|
||||
if (this.episode) return this.episode.title
|
||||
return this.mediaMetadata.title || ''
|
||||
},
|
||||
bookAuthors() {
|
||||
if (this.episode) return []
|
||||
return this.mediaMetadata.authors || []
|
||||
},
|
||||
bookAuthorName() {
|
||||
return this.bookAuthors.map((au) => au.name).join(', ')
|
||||
},
|
||||
itemDuration() {
|
||||
if (this.episode) return this.$elapsedPretty(this.episode.duration)
|
||||
return this.$elapsedPretty(this.media.duration)
|
||||
},
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
coverWidth() {
|
||||
return 50
|
||||
},
|
||||
isMissing() {
|
||||
return this.libraryItem.isMissing
|
||||
},
|
||||
isInvalid() {
|
||||
return this.libraryItem.isInvalid
|
||||
},
|
||||
isStreaming() {
|
||||
return this.$store.getters['getIsItemStreaming'](this.item.id)
|
||||
},
|
||||
showPlayBtn() {
|
||||
return !this.isMissing && !this.isInvalid && !this.isStreaming && (this.tracks.length || this.episode)
|
||||
}
|
||||
},
|
||||
methods: {},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.item-table-content {
|
||||
width: calc(100% - 50px);
|
||||
max-width: calc(100% - 50px);
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full px-0 py-4 overflow-hidden relative border-b border-white border-opacity-10">
|
||||
<div class="w-full py-4 overflow-hidden relative border-b border-white border-opacity-10">
|
||||
<div v-if="episode" class="w-full px-1">
|
||||
<!-- Help debug for testing -->
|
||||
<!-- <template>
|
||||
@@ -26,14 +26,28 @@
|
||||
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
|
||||
<button v-if="!isLocal" class="mx-1.5 mt-1.5" @click="addToPlaylist">
|
||||
<span class="material-icons text-2xl">playlist_add</span>
|
||||
</button>
|
||||
|
||||
<div v-if="userCanDownload">
|
||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
<span v-else-if="!localEpisode" class="material-icons mx-1.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<button v-if="!isLocal && isAdminOrUp" class="mx-1.5 mt-1.5" @click="deleteEpisodeFromServerClick">
|
||||
<span class="material-icons text-xl">delete_forever</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="la-lg" />
|
||||
</div>
|
||||
|
||||
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -58,10 +72,14 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false
|
||||
isProcessingReadUpdate: false,
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
@@ -134,6 +152,32 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async deleteEpisodeFromServerClick() {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.`
|
||||
})
|
||||
|
||||
if (value) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItemId}/episode/${this.episode.id}?hard=1`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode deleted from server')
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to delete episode'
|
||||
console.error('Failed to delete episode', error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
addToPlaylist() {
|
||||
this.$emit('addToPlaylist', this.episode)
|
||||
},
|
||||
async selectFolder() {
|
||||
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
|
||||
if (folderObj.error) {
|
||||
@@ -141,8 +185,9 @@ export default {
|
||||
}
|
||||
return folderObj
|
||||
},
|
||||
downloadClick() {
|
||||
async downloadClick() {
|
||||
if (this.downloadItem) return
|
||||
await this.$hapticsImpact()
|
||||
if (this.isIos) {
|
||||
// no local folders on iOS
|
||||
this.startDownload()
|
||||
@@ -202,7 +247,8 @@ export default {
|
||||
this.$toast.error(errorMsg)
|
||||
}
|
||||
},
|
||||
playClick() {
|
||||
async playClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
} else {
|
||||
@@ -224,6 +270,7 @@ export default {
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
await this.$hapticsImpact()
|
||||
this.isProcessingReadUpdate = true
|
||||
if (this.isLocal || this.localEpisode) {
|
||||
var isFinished = !this.userIsFinished
|
||||
@@ -271,4 +318,4 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
@@ -1,8 +1,32 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<!-- Podcast episode downloads queue -->
|
||||
<div v-if="episodeDownloadsQueued.length" class="px-4 py-2 my-2 bg-info bg-opacity-40 text-sm font-semibold rounded-md text-gray-100 relative w-full">
|
||||
<div class="flex items-center">
|
||||
<p class="text-sm py-1">{{ episodeDownloadsQueued.length }} Episode(s) queued for download</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="isAdminOrUp" class="material-icons text-xl ml-3 cursor-pointer" @click="clearDownloadQueue">close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Podcast episodes currently downloading -->
|
||||
<div v-if="episodesDownloading.length" class="px-4 py-2 my-2 bg-success bg-opacity-20 text-sm font-semibold rounded-md text-gray-100 relative w-full">
|
||||
<div v-for="episode in episodesDownloading" :key="episode.id" class="flex items-center">
|
||||
<widgets-loading-spinner />
|
||||
<p class="text-sm py-1 pl-4">Downloading episode "{{ episode.episodeDisplayTitle }}"</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center">
|
||||
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<button v-if="isAdminOrUp && !fetchingRSSFeed" class="outline:none mx-1 pt-0.5 relative" @click="searchEpisodes">
|
||||
<span class="material-icons text-xl text-gray-200">search</span>
|
||||
</button>
|
||||
<widgets-loading-spinner v-else-if="fetchingRSSFeed" class="mx-1" />
|
||||
|
||||
<button class="outline:none mx-3 pt-0.5 relative" @click="showFilters">
|
||||
<span class="material-icons text-xl text-gray-200">filter_alt</span>
|
||||
<div v-show="filterKey !== 'all' && episodesAreFiltered" class="absolute top-0 right-0 w-1.5 h-1.5 rounded-full bg-success border border-green-300 shadow-sm z-10 pointer-events-none" />
|
||||
@@ -15,21 +39,28 @@
|
||||
</div>
|
||||
|
||||
<template v-for="episode in episodesSorted">
|
||||
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" />
|
||||
<tables-podcast-episode-row :episode="episode" :local-episode="localEpisodeMap[episode.id]" :library-item-id="libraryItemId" :local-library-item-id="localLibraryItemId" :is-local="isLocal" :key="episode.id" @addToPlaylist="addEpisodeToPlaylist" />
|
||||
</template>
|
||||
|
||||
<!-- What in tarnation is going on here?
|
||||
<!-- Huhhh?
|
||||
Without anything below the template it will not re-render -->
|
||||
<p> </p>
|
||||
|
||||
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
|
||||
|
||||
<modals-podcast-episodes-feed-modal v-model="showPodcastEpisodeFeed" :library-item="libraryItem" :episodes="podcastFeedEpisodes" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
libraryItem: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
episodes: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
@@ -83,7 +114,12 @@ export default {
|
||||
text: 'Complete',
|
||||
value: 'complete'
|
||||
}
|
||||
]
|
||||
],
|
||||
fetchingRSSFeed: false,
|
||||
podcastFeedEpisodes: [],
|
||||
showPodcastEpisodeFeed: false,
|
||||
episodesDownloading: [],
|
||||
episodeDownloadsQueued: []
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
@@ -95,6 +131,21 @@ export default {
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
libraryItemId() {
|
||||
return this.libraryItem ? this.libraryItem.id : null
|
||||
},
|
||||
media() {
|
||||
return this.libraryItem ? this.libraryItem.media || {} : {}
|
||||
},
|
||||
mediaMetadata() {
|
||||
return this.media.metadata || {}
|
||||
},
|
||||
episodesAreFiltered() {
|
||||
return this.episodesFiltered.length !== this.episodesCopy.length
|
||||
},
|
||||
@@ -140,9 +191,58 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async clearDownloadQueue() {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to clear episode download queue?`
|
||||
})
|
||||
|
||||
if (value) {
|
||||
this.$axios
|
||||
.$get(`/api/podcasts/${this.libraryItemId}/clear-queue`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode download queue cleared')
|
||||
this.episodeDownloadQueued = []
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Failed to clear queue', error)
|
||||
this.$toast.error('Failed to clear queue')
|
||||
})
|
||||
}
|
||||
},
|
||||
async searchEpisodes() {
|
||||
if (!this.networkConnected) {
|
||||
return this.$toast.error('No network connection')
|
||||
}
|
||||
|
||||
if (!this.mediaMetadata.feedUrl) {
|
||||
return this.$toast.error('Podcast does not have an RSS Feed')
|
||||
}
|
||||
this.fetchingRSSFeed = true
|
||||
const payload = await this.$axios.$post(`/api/podcasts/feed`, { rssFeed: this.mediaMetadata.feedUrl }).catch((error) => {
|
||||
console.error('Failed to get feed', error)
|
||||
this.$toast.error('Failed to get podcast feed')
|
||||
return null
|
||||
})
|
||||
this.fetchingRSSFeed = false
|
||||
if (!payload) return
|
||||
|
||||
console.log('Podcast feed', payload)
|
||||
const podcastfeed = payload.podcast
|
||||
if (!podcastfeed.episodes || !podcastfeed.episodes.length) {
|
||||
this.$toast.info('No episodes found in RSS feed')
|
||||
return
|
||||
}
|
||||
|
||||
this.podcastFeedEpisodes = podcastfeed.episodes
|
||||
this.showPodcastEpisodeFeed = true
|
||||
},
|
||||
addEpisodeToPlaylist(episode) {
|
||||
this.$store.commit('globals/setSelectedPlaylistItems', [{ libraryItem: this.libraryItem, episode }])
|
||||
this.$store.commit('globals/setShowPlaylistsAddCreateModal', true)
|
||||
},
|
||||
setFilter(filter) {
|
||||
this.filterKey = filter
|
||||
console.log('Set filter', this.filterKey)
|
||||
this.showFiltersModal = false
|
||||
},
|
||||
showFilters() {
|
||||
@@ -159,8 +259,34 @@ export default {
|
||||
this.episodesCopy = this.episodes.map((ep) => {
|
||||
return { ...ep }
|
||||
})
|
||||
},
|
||||
episodeDownloadQueued(episodeDownload) {
|
||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||
this.episodeDownloadsQueued.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadStarted(episodeDownload) {
|
||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading.push(episodeDownload)
|
||||
}
|
||||
},
|
||||
episodeDownloadFinished(episodeDownload) {
|
||||
if (episodeDownload.libraryItemId === this.libraryItemId) {
|
||||
this.episodeDownloadsQueued = this.episodeDownloadsQueued.filter((d) => d.id !== episodeDownload.id)
|
||||
this.episodesDownloading = this.episodesDownloading.filter((d) => d.id !== episodeDownload.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
mounted() {
|
||||
this.$socket.$on('episode_download_queued', this.episodeDownloadQueued)
|
||||
this.$socket.$on('episode_download_started', this.episodeDownloadStarted)
|
||||
this.$socket.$on('episode_download_finished', this.episodeDownloadFinished)
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$socket.$off('episode_download_queued', this.episodeDownloadQueued)
|
||||
this.$socket.$off('episode_download_started', this.episodeDownloadStarted)
|
||||
this.$socket.$off('episode_download_finished', this.episodeDownloadFinished)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,327 @@
|
||||
<template>
|
||||
<div class="w-full py-4 overflow-hidden relative border-b border-white border-opacity-10">
|
||||
<div v-if="episode" class="w-full px-1">
|
||||
<div class="flex mb-2">
|
||||
<div class="w-10 min-w-10">
|
||||
<covers-preview-cover :src="$store.getters['globals/getLibraryItemCoverSrcById'](libraryItemId)" :width="40" :book-cover-aspect-ratio="bookCoverAspectRatio" :show-resolution="false" class="md:hidden" />
|
||||
</div>
|
||||
<div class="flex-grow px-2">
|
||||
<div class="-mt-0.5 mb-0.5">
|
||||
<nuxt-link :to="`/item/${libraryItemId}`" class="text-sm text-gray-200 underline">{{ podcast.metadata.title }}</nuxt-link>
|
||||
</div>
|
||||
<p v-if="publishedAt" class="text-xs text-gray-300">{{ $dateDistanceFromNow(publishedAt) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-200 episode-subtitle mt-1.5 mb-0.5 default-style" v-html="description" />
|
||||
|
||||
<div class="flex items-center pt-2">
|
||||
<div class="h-8 px-4 border border-white border-opacity-20 hover:bg-white hover:bg-opacity-10 rounded-full flex items-center justify-center cursor-pointer" :class="userIsFinished ? 'text-white text-opacity-40' : ''" @click="playClick">
|
||||
<span class="material-icons" :class="streamIsPlaying ? '' : 'text-success'">{{ streamIsPlaying ? 'pause' : 'play_arrow' }}</span>
|
||||
<p class="pl-2 pr-1 text-sm font-semibold">{{ timeRemaining }}</p>
|
||||
</div>
|
||||
|
||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||
|
||||
<button v-if="!isLocal" class="mx-1.5 mt-1.5" @click="addToPlaylist">
|
||||
<span class="material-icons text-2xl">playlist_add</span>
|
||||
</button>
|
||||
|
||||
<div v-if="userCanDownload">
|
||||
<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.5 mt-2 text-xl" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75' : ''" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
||||
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||
</div>
|
||||
|
||||
<div class="flex-grow" />
|
||||
|
||||
<button v-if="!isLocal && isAdminOrUp" class="mx-1.5 mt-1.5" @click="deleteEpisodeFromServerClick">
|
||||
<span class="material-icons text-xl">delete_forever</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="processing" class="absolute top-0 left-0 w-full h-full bg-black bg-opacity-30 flex items-center justify-center">
|
||||
<widgets-loading-spinner size="la-lg" />
|
||||
</div>
|
||||
|
||||
<div v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
import { AbsFileSystem, AbsDownloader } from '@/plugins/capacitor'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
libraryItemId: String,
|
||||
episode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
localLibraryItemId: String,
|
||||
localEpisode: {
|
||||
type: Object,
|
||||
default: () => {}
|
||||
},
|
||||
isLocal: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isProcessingReadUpdate: false,
|
||||
processing: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
bookCoverAspectRatio() {
|
||||
return this.$store.getters['libraries/getBookCoverAspectRatio']
|
||||
},
|
||||
isAdminOrUp() {
|
||||
return this.$store.getters['user/getIsAdminOrUp']
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
mediaType() {
|
||||
return 'podcast'
|
||||
},
|
||||
userCanDownload() {
|
||||
return this.$store.getters['user/getUserCanDownload']
|
||||
},
|
||||
audioFile() {
|
||||
return this.episode.audioFile
|
||||
},
|
||||
title() {
|
||||
return this.episode.title || ''
|
||||
},
|
||||
description() {
|
||||
if (this.episode.subtitle) return this.episode.subtitle
|
||||
var desc = this.episode.description || ''
|
||||
return desc
|
||||
},
|
||||
duration() {
|
||||
return this.$secondsToTimestamp(this.episode.duration)
|
||||
},
|
||||
isStreaming() {
|
||||
if (this.playerIsLocal && this.localLibraryItemId && this.localEpisode) {
|
||||
// Check is streaming local version of this episode
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.localLibraryItemId, this.localEpisode.id)
|
||||
}
|
||||
return this.$store.getters['getIsEpisodeStreaming'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
playerIsLocal() {
|
||||
return !!this.$store.state.playerIsLocal
|
||||
},
|
||||
streamIsPlaying() {
|
||||
return this.$store.state.playerIsPlaying && this.isStreaming
|
||||
},
|
||||
itemProgress() {
|
||||
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
localMediaProgress() {
|
||||
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId, this.episode.id)
|
||||
else if (this.localLibraryItemId && this.localEpisode) {
|
||||
return this.$store.getters['globals/getLocalMediaProgressById'](this.localLibraryItemId, this.localEpisode.id)
|
||||
} else {
|
||||
return null
|
||||
}
|
||||
},
|
||||
itemProgressPercent() {
|
||||
return this.itemProgress ? this.itemProgress.progress : 0
|
||||
},
|
||||
userIsFinished() {
|
||||
return this.itemProgress ? !!this.itemProgress.isFinished : false
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.streamIsPlaying) return 'Playing'
|
||||
if (!this.itemProgressPercent) return this.$elapsedPretty(this.episode.duration)
|
||||
if (this.userIsFinished) return 'Finished'
|
||||
var remaining = Math.floor(this.itemProgress.duration - this.itemProgress.currentTime)
|
||||
return `${this.$elapsedPretty(remaining)} left`
|
||||
},
|
||||
publishedAt() {
|
||||
return this.episode.publishedAt
|
||||
},
|
||||
downloadItem() {
|
||||
return this.$store.getters['globals/getDownloadItem'](this.libraryItemId, this.episode.id)
|
||||
},
|
||||
localEpisodeId() {
|
||||
return this.localEpisode ? this.localEpisode.id : null
|
||||
},
|
||||
podcast() {
|
||||
return this.episode.podcast || {}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async deleteEpisodeFromServerClick() {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: `Are you sure you want to delete episode "${this.title}" from the server?\nWarning: This will delete the audio file.`
|
||||
})
|
||||
|
||||
if (value) {
|
||||
this.processing = true
|
||||
this.$axios
|
||||
.$delete(`/api/podcasts/${this.libraryItemId}/episode/${this.episode.id}?hard=1`)
|
||||
.then(() => {
|
||||
this.$toast.success('Episode deleted from server')
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMsg = error.response && error.response.data ? error.response.data : 'Failed to delete episode'
|
||||
console.error('Failed to delete episode', error)
|
||||
this.$toast.error(errorMsg)
|
||||
})
|
||||
.finally(() => {
|
||||
this.processing = false
|
||||
})
|
||||
}
|
||||
},
|
||||
addToPlaylist() {
|
||||
this.$emit('addToPlaylist', this.episode)
|
||||
},
|
||||
async selectFolder() {
|
||||
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
|
||||
if (folderObj.error) {
|
||||
return this.$toast.error(`Error: ${folderObj.error || 'Unknown Error'}`)
|
||||
}
|
||||
return folderObj
|
||||
},
|
||||
async downloadClick() {
|
||||
if (this.downloadItem) return
|
||||
await this.$hapticsImpact()
|
||||
if (this.isIos) {
|
||||
// no local folders on iOS
|
||||
this.startDownload()
|
||||
} else {
|
||||
this.download()
|
||||
}
|
||||
},
|
||||
async download(selectedLocalFolder = null) {
|
||||
var localFolder = selectedLocalFolder
|
||||
if (!localFolder) {
|
||||
var localFolders = (await this.$db.getLocalFolders()) || []
|
||||
console.log('Local folders loaded', localFolders.length)
|
||||
var foldersWithMediaType = localFolders.filter((lf) => {
|
||||
console.log('Checking local folder', lf.mediaType)
|
||||
return lf.mediaType == this.mediaType
|
||||
})
|
||||
console.log('Folders with media type', this.mediaType, foldersWithMediaType.length)
|
||||
if (!foldersWithMediaType.length) {
|
||||
// No local folders or no local folders with this media type
|
||||
localFolder = await this.selectFolder()
|
||||
} else if (foldersWithMediaType.length == 1) {
|
||||
console.log('Only 1 local folder with this media type - auto select it')
|
||||
localFolder = foldersWithMediaType[0]
|
||||
} else {
|
||||
console.log('Multiple folders with media type')
|
||||
// this.showSelectLocalFolder = true
|
||||
return
|
||||
}
|
||||
if (!localFolder) {
|
||||
return this.$toast.error('Invalid download folder')
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Local folder', JSON.stringify(localFolder))
|
||||
|
||||
var startDownloadMessage = `Start download for "${this.title}" to folder ${localFolder.name}?`
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: startDownloadMessage
|
||||
})
|
||||
if (value) {
|
||||
this.startDownload(localFolder)
|
||||
}
|
||||
},
|
||||
async startDownload(localFolder) {
|
||||
var payload = {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episode.id
|
||||
}
|
||||
if (localFolder) {
|
||||
payload.localFolderId = localFolder.id
|
||||
}
|
||||
var downloadRes = await AbsDownloader.downloadLibraryItem(payload)
|
||||
if (downloadRes && downloadRes.error) {
|
||||
var errorMsg = downloadRes.error || 'Unknown error'
|
||||
console.error('Download error', errorMsg)
|
||||
this.$toast.error(errorMsg)
|
||||
}
|
||||
},
|
||||
async playClick() {
|
||||
await this.$hapticsImpact()
|
||||
if (this.streamIsPlaying) {
|
||||
this.$eventBus.$emit('pause-item')
|
||||
} else {
|
||||
if (this.localEpisode && this.localLibraryItemId) {
|
||||
console.log('Play local episode', this.localEpisode.id, this.localLibraryItemId)
|
||||
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.localLibraryItemId,
|
||||
episodeId: this.localEpisode.id,
|
||||
serverLibraryItemId: this.libraryItemId,
|
||||
serverEpisodeId: this.episode.id
|
||||
})
|
||||
} else {
|
||||
this.$eventBus.$emit('play-item', {
|
||||
libraryItemId: this.libraryItemId,
|
||||
episodeId: this.episode.id
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
async toggleFinished() {
|
||||
await this.$hapticsImpact()
|
||||
this.isProcessingReadUpdate = true
|
||||
if (this.isLocal || this.localEpisode) {
|
||||
var isFinished = !this.userIsFinished
|
||||
var localLibraryItemId = this.isLocal ? this.libraryItemId : this.localLibraryItemId
|
||||
var localEpisodeId = this.isLocal ? this.episode.id : this.localEpisode.id
|
||||
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'
|
||||
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, this.episode.id)
|
||||
console.log('toggleFinished Check LMP', this.libraryItemId, this.episode.id, 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}/${this.episode.id}`, 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'}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div class="relative w-full" v-click-outside="clickOutsideObj">
|
||||
<div class="relative w-full" v-click-outside="clickedOutside">
|
||||
<p class="text-sm font-semibold" :class="disabled ? 'text-gray-300' : ''">{{ label }}</p>
|
||||
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none sm:text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<button type="button" :disabled="disabled" class="relative w-full border rounded shadow-sm pl-3 pr-8 py-2 text-left focus:outline-none text-sm" :class="buttonClass" aria-haspopup="listbox" aria-expanded="true" @click.stop.prevent="clickShowMenu">
|
||||
<span class="flex items-center" :class="!selectedText ? 'text-gray-300' : 'text-white'">
|
||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
|
||||
</span>
|
||||
@@ -11,9 +11,9 @@
|
||||
</button>
|
||||
|
||||
<transition name="menu">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox">
|
||||
<ul v-show="showMenu" class="absolute z-10 -mt-px w-full bg-primary border border-gray-600 shadow-lg max-h-56 rounded-b-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox">
|
||||
<template v-for="item in items">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" role="option" @click="clickedOption(item.value)">
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
|
||||
</div>
|
||||
@@ -42,11 +42,6 @@ export default {
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
clickOutsideObj: {
|
||||
handler: this.clickedOutside,
|
||||
events: ['mousedown'],
|
||||
isActive: true
|
||||
},
|
||||
showMenu: false
|
||||
}
|
||||
},
|
||||
@@ -66,7 +61,7 @@ export default {
|
||||
return this.selectedItem ? this.selectedItem.text : ''
|
||||
},
|
||||
buttonClass() {
|
||||
var classes = []
|
||||
const classes = []
|
||||
if (this.small) classes.push('h-9')
|
||||
else classes.push('h-10')
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<slot />
|
||||
</div>
|
||||
<transition name="menu">
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-50 -mt-px bg-primary border border-gray-600 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" tabindex="-1" role="listbox" aria-activedescendant="listbox-option-3" style="width: 160px">
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-50 -mt-px bg-primary border border-gray-600 shadow-lg max-h-56 rounded-md py-1 text-base ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none sm:text-sm" role="listbox" style="width: 160px">
|
||||
<template v-for="item in items">
|
||||
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
|
||||
<li :key="item.value" class="text-gray-100 select-none relative py-2 cursor-pointer hover:bg-black-400" id="listbox-option-0" role="option" @click="clickedOption(item.value)">
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<div ref="wrapper" class="relative">
|
||||
<form @submit.prevent="submitForm">
|
||||
<div ref="inputWrapper" style="min-height: 36px" class="flex-wrap relative w-full shadow-sm flex items-center border border-gray-600 rounded px-2 py-1" :class="wrapperClass" @click.stop.prevent="clickWrapper" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div v-for="item in selected" :key="item" class="rounded-full px-2 py-1 mx-0.5 my-0.5 text-xs bg-bg flex flex-nowrap break-all items-center relative">
|
||||
<div v-if="!disabled" class="w-full h-full rounded-full absolute top-0 left-0 px-1 bg-bg bg-opacity-75 flex items-center justify-end opacity-0 hover:opacity-100">
|
||||
<span v-if="showEdit" class="material-icons text-white hover:text-warning cursor-pointer" style="font-size: 1.1rem" @click.stop="editItem(item)">edit</span>
|
||||
<span class="material-icons text-white hover:text-error cursor-pointer" style="font-size: 1.1rem" @click.stop="removeItem(item)">close</span>
|
||||
</div>
|
||||
{{ item }}
|
||||
</div>
|
||||
<input v-show="!readonly" ref="input" v-model="textInput" :disabled="disabled" style="min-width: 40px; width: 40px" class="h-full bg-primary focus:outline-none px-1" @keydown="keydownInput" @focus="inputFocus" @blur="inputBlur" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ul ref="menu" v-show="showMenu" class="absolute z-50 mt-1 w-full bg-bg border border-gray-600 shadow-lg max-h-56 rounded-md py-1 ring-1 ring-black ring-opacity-5 overflow-auto focus:outline-none text-sm" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in itemsToShow">
|
||||
<li :key="item" class="text-gray-50 select-none relative py-2 pr-9 cursor-pointer" role="option" @click="clickedOption($event, item)" @mouseup.stop.prevent @mousedown.prevent>
|
||||
<div class="flex items-center">
|
||||
<span class="font-normal ml-3 block truncate">{{ item }}</span>
|
||||
</div>
|
||||
<span v-if="selected.includes(item)" class="text-yellow-400 absolute inset-y-0 right-0 flex items-center pr-4">
|
||||
<span class="material-icons text-xl">checkmark</span>
|
||||
</span>
|
||||
</li>
|
||||
</template>
|
||||
<li v-if="!itemsToShow.length" class="text-gray-50 select-none relative py-2 pr-9" role="option">
|
||||
<div class="flex items-center justify-center">
|
||||
<span class="font-normal">No Items</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
readonly: Boolean,
|
||||
showEdit: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
textInput: null,
|
||||
currentSearch: null,
|
||||
typingTimeout: null,
|
||||
isFocused: false,
|
||||
menu: null
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
showMenu(newVal) {
|
||||
if (newVal) this.setListener()
|
||||
else this.removeListener()
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
selected: {
|
||||
get() {
|
||||
return this.value || []
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
},
|
||||
showMenu() {
|
||||
return this.isFocused
|
||||
},
|
||||
wrapperClass() {
|
||||
var classes = []
|
||||
if (this.disabled) classes.push('bg-black-300')
|
||||
else classes.push('bg-primary')
|
||||
if (!this.readonly) classes.push('cursor-text')
|
||||
return classes.join(' ')
|
||||
},
|
||||
itemsToShow() {
|
||||
if (!this.currentSearch || !this.textInput) {
|
||||
return this.items
|
||||
}
|
||||
|
||||
return this.items.filter((i) => {
|
||||
var iValue = String(i).toLowerCase()
|
||||
return iValue.includes(this.currentSearch.toLowerCase())
|
||||
})
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
editItem(item) {
|
||||
this.$emit('edit', item)
|
||||
},
|
||||
keydownInput() {
|
||||
clearTimeout(this.typingTimeout)
|
||||
this.typingTimeout = setTimeout(() => {
|
||||
this.currentSearch = this.textInput
|
||||
}, 100)
|
||||
this.setInputWidth()
|
||||
},
|
||||
setInputWidth() {
|
||||
setTimeout(() => {
|
||||
var value = this.$refs.input.value
|
||||
var len = value.length * 7 + 24
|
||||
this.$refs.input.style.width = len + 'px'
|
||||
this.recalcMenuPos()
|
||||
}, 50)
|
||||
},
|
||||
recalcMenuPos() {
|
||||
if (!this.menu) return
|
||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||
if (boundingBox.y > window.innerHeight - 8) {
|
||||
// Input is off the page
|
||||
return this.forceBlur()
|
||||
}
|
||||
var menuHeight = this.menu.clientHeight
|
||||
var top = boundingBox.y + boundingBox.height - 4
|
||||
if (top + menuHeight > window.innerHeight - 20) {
|
||||
// Reverse menu to open upwards
|
||||
top = boundingBox.y - menuHeight - 4
|
||||
}
|
||||
|
||||
this.menu.style.top = top + 'px'
|
||||
this.menu.style.left = boundingBox.x + 'px'
|
||||
this.menu.style.width = boundingBox.width + 'px'
|
||||
},
|
||||
unmountMountMenu() {
|
||||
if (!this.$refs.menu) return
|
||||
this.menu = this.$refs.menu
|
||||
|
||||
var boundingBox = this.$refs.inputWrapper.getBoundingClientRect()
|
||||
this.menu.remove()
|
||||
document.body.appendChild(this.menu)
|
||||
this.menu.style.top = boundingBox.y + boundingBox.height - 4 + 'px'
|
||||
this.menu.style.left = boundingBox.x + 'px'
|
||||
this.menu.style.width = boundingBox.width + 'px'
|
||||
},
|
||||
inputFocus() {
|
||||
if (!this.menu) {
|
||||
this.unmountMountMenu()
|
||||
}
|
||||
this.isFocused = true
|
||||
this.$nextTick(this.recalcMenuPos)
|
||||
},
|
||||
inputBlur() {
|
||||
if (!this.isFocused) return
|
||||
|
||||
setTimeout(() => {
|
||||
if (document.activeElement === this.$refs.input) {
|
||||
return
|
||||
}
|
||||
this.isFocused = false
|
||||
if (this.textInput) this.submitForm()
|
||||
}, 50)
|
||||
},
|
||||
focus() {
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
forceBlur() {
|
||||
this.isFocused = false
|
||||
if (this.textInput) this.submitForm()
|
||||
if (this.$refs.input) this.$refs.input.blur()
|
||||
},
|
||||
clickedOption(e, itemValue) {
|
||||
if (e) {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
}
|
||||
if (this.$refs.input) this.$refs.input.focus()
|
||||
|
||||
var newSelected = null
|
||||
if (this.selected.includes(itemValue)) {
|
||||
newSelected = this.selected.filter((s) => s !== itemValue)
|
||||
this.$emit('removedItem', itemValue)
|
||||
} else {
|
||||
newSelected = this.selected.concat([itemValue])
|
||||
}
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$emit('input', newSelected)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
clickWrapper() {
|
||||
if (this.disabled) return
|
||||
if (this.showMenu) {
|
||||
return this.blur()
|
||||
}
|
||||
this.focus()
|
||||
},
|
||||
removeItem(item) {
|
||||
var remaining = this.selected.filter((i) => i !== item)
|
||||
this.$emit('input', remaining)
|
||||
this.$emit('removedItem', item)
|
||||
this.$nextTick(() => {
|
||||
this.recalcMenuPos()
|
||||
})
|
||||
},
|
||||
insertNewItem(item) {
|
||||
this.selected.push(item)
|
||||
this.$emit('input', this.selected)
|
||||
this.$emit('newItem', item)
|
||||
this.textInput = null
|
||||
this.currentSearch = null
|
||||
this.$nextTick(() => {
|
||||
this.blur()
|
||||
})
|
||||
},
|
||||
submitForm() {
|
||||
if (!this.textInput) return
|
||||
|
||||
var cleaned = this.textInput.trim()
|
||||
var matchesItem = this.items.find((i) => {
|
||||
return i === cleaned
|
||||
})
|
||||
if (matchesItem) {
|
||||
this.clickedOption(null, matchesItem)
|
||||
} else {
|
||||
this.insertNewItem(this.textInput)
|
||||
}
|
||||
},
|
||||
scroll() {
|
||||
this.recalcMenuPos()
|
||||
},
|
||||
setListener() {
|
||||
document.addEventListener('scroll', this.scroll, true)
|
||||
},
|
||||
removeListener() {
|
||||
document.removeEventListener('scroll', this.scroll, true)
|
||||
}
|
||||
},
|
||||
mounted() {},
|
||||
beforeDestroy() {
|
||||
if (this.menu) this.menu.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input {
|
||||
border-style: inherit !important;
|
||||
}
|
||||
input:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
@@ -1,9 +1,15 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none" :class="inputClass" @keyup="keyup" />
|
||||
<input v-model="input" ref="input" autofocus :type="type" :disabled="disabled" :readonly="readonly" autocorrect="off" autocapitalize="none" autocomplete="off" :placeholder="placeholder" class="py-2 w-full outline-none bg-primary" :class="inputClass" @keyup="keyup" />
|
||||
<div v-if="prependIcon" class="absolute top-0 left-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-lg">{{ prependIcon }}</span>
|
||||
</div>
|
||||
<div v-if="clearable && input" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center" @click.stop="clear">
|
||||
<span class="material-icons text-lg">close</span>
|
||||
</div>
|
||||
<div v-else-if="!clearable && appendIcon" class="absolute top-0 right-0 h-full px-2 flex items-center justify-center">
|
||||
<span class="material-icons text-lg">{{ appendIcon }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -14,6 +20,7 @@ export default {
|
||||
placeholder: String,
|
||||
type: String,
|
||||
disabled: Boolean,
|
||||
readonly: Boolean,
|
||||
borderless: Boolean,
|
||||
bg: {
|
||||
type: String,
|
||||
@@ -27,10 +34,11 @@ export default {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
textSize: {
|
||||
appendIcon: {
|
||||
type: String,
|
||||
default: 'lg'
|
||||
}
|
||||
default: null
|
||||
},
|
||||
clearable: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
@@ -45,7 +53,7 @@ export default {
|
||||
}
|
||||
},
|
||||
inputClass() {
|
||||
var classes = [`bg-${this.bg}`, `rounded-${this.rounded}`, `text-${this.textSize}`]
|
||||
var classes = [`bg-${this.bg}`, `rounded-${this.rounded}`]
|
||||
if (this.disabled) classes.push('text-gray-300')
|
||||
else classes.push('text-white')
|
||||
|
||||
@@ -57,6 +65,9 @@ export default {
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
clear() {
|
||||
this.input = ''
|
||||
},
|
||||
focus() {
|
||||
if (this.$refs.input) {
|
||||
this.$refs.input.focus()
|
||||
@@ -71,4 +82,10 @@ export default {
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type='time']::-webkit-calendar-picker-indicator {
|
||||
filter: invert(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<textarea ref="input" v-model="inputValue" :rows="rows" :readonly="readonly" :disabled="disabled" :placeholder="placeholder" class="py-2 px-3 rounded bg-primary text-gray-200 focus:border-gray-500 focus:outline-none" :class="transparent ? '' : 'border border-gray-600'" @change="change" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
placeholder: String,
|
||||
readonly: Boolean,
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 2
|
||||
},
|
||||
transparent: Boolean,
|
||||
disabled: Boolean
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
inputValue: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
change(e) {
|
||||
this.$emit('change', e.target.value)
|
||||
},
|
||||
blur() {
|
||||
if (this.$refs.input && this.$refs.input.blur) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
textarea {
|
||||
border-style: inherit !important;
|
||||
}
|
||||
textarea:read-only {
|
||||
color: #aaa;
|
||||
background-color: #444;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<p class="px-1 text-sm font-semibold" :class="disabled ? 'text-gray-400' : ''">{{ label }}</p>
|
||||
<ui-textarea-input ref="input" v-model="inputValue" :disabled="disabled" :rows="rows" class="w-full" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
value: [String, Number],
|
||||
label: String,
|
||||
disabled: Boolean,
|
||||
rows: {
|
||||
type: Number,
|
||||
default: 2
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {}
|
||||
},
|
||||
computed: {
|
||||
inputValue: {
|
||||
get() {
|
||||
return this.value
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('input', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
blur() {
|
||||
if (this.$refs.input && this.$refs.input.blur) {
|
||||
this.$refs.input.blur()
|
||||
}
|
||||
}
|
||||
},
|
||||
mounted() {}
|
||||
}
|
||||
</script>
|
||||
@@ -34,12 +34,12 @@ export default {
|
||||
computed: {},
|
||||
methods: {
|
||||
updateProgress() {
|
||||
var progbar = this.$refs.progressbar
|
||||
var circle = this.$refs.circle
|
||||
const progbar = this.$refs.progressbar
|
||||
const circle = this.$refs.circle
|
||||
if (!progbar || !circle) return
|
||||
|
||||
clearTimeout(this.updateTimeout)
|
||||
var progress = Math.min(this.value || 0, 1)
|
||||
const progress = Math.min(this.value || 0, 1)
|
||||
|
||||
progbar.style.setProperty('--progress-percent-before', this.lastProgress)
|
||||
progbar.style.setProperty('--progress-percent', progress)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="numPartsRemaining > 0">
|
||||
<widgets-circle-progress :value="progress" :count="numPartsRemaining" />
|
||||
<div v-if="downloadItemPartsRemaining.length" @click="clickedIt">
|
||||
<widgets-circle-progress :value="progress" :count="downloadItemPartsRemaining.length" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,73 +10,42 @@ import { AbsDownloader } from '@/plugins/capacitor'
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
updateListener: null,
|
||||
downloadItemListener: null,
|
||||
completeListener: null,
|
||||
itemDownloadingMap: {}
|
||||
itemPartUpdateListener: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
numItemPartsComplete() {
|
||||
var total = 0
|
||||
Object.values(this.itemDownloadingMap).map((item) => (total += item.partsCompleted))
|
||||
return total
|
||||
downloadItems() {
|
||||
return this.$store.state.globals.itemDownloads
|
||||
},
|
||||
numPartsRemaining() {
|
||||
return this.numTotalParts - this.numItemPartsComplete
|
||||
downloadItemParts() {
|
||||
let parts = []
|
||||
this.downloadItems.forEach((di) => parts.push(...di.downloadItemParts))
|
||||
return parts
|
||||
},
|
||||
numTotalParts() {
|
||||
var total = 0
|
||||
Object.values(this.itemDownloadingMap).map((item) => (total += item.totalParts))
|
||||
return total
|
||||
downloadItemPartsRemaining() {
|
||||
return this.downloadItemParts.filter((dip) => !dip.completed)
|
||||
},
|
||||
progress() {
|
||||
var numItems = Object.keys(this.itemDownloadingMap).length
|
||||
if (!numItems) return 0
|
||||
var totalProg = 0
|
||||
Object.values(this.itemDownloadingMap).map((item) => (totalProg += item.itemProgress))
|
||||
return totalProg / numItems
|
||||
let totalBytes = 0
|
||||
let totalBytesDownloaded = 0
|
||||
this.downloadItemParts.forEach((dip) => {
|
||||
totalBytes += dip.fileSize
|
||||
totalBytesDownloaded += dip.bytesDownloaded
|
||||
})
|
||||
|
||||
if (!totalBytes) return 0
|
||||
return Math.min(1, totalBytesDownloaded / totalBytes)
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
onItemDownloadUpdate(data) {
|
||||
console.log('DownloadProgressIndicator onItemDownloadUpdate', JSON.stringify(data))
|
||||
if (!data || !data.downloadItemParts) {
|
||||
console.error('Invalid item update payload')
|
||||
return
|
||||
}
|
||||
var downloadItemParts = data.downloadItemParts
|
||||
var partsCompleted = 0
|
||||
var totalPartsProgress = 0
|
||||
var partsRemaining = 0
|
||||
downloadItemParts.forEach((dip) => {
|
||||
if (dip.completed) {
|
||||
totalPartsProgress += 1
|
||||
partsCompleted++
|
||||
} else {
|
||||
var progPercent = dip.progress / 100
|
||||
totalPartsProgress += progPercent
|
||||
partsRemaining++
|
||||
}
|
||||
})
|
||||
var itemProgress = totalPartsProgress / downloadItemParts.length
|
||||
|
||||
var update = {
|
||||
id: data.id,
|
||||
libraryItemId: data.libraryItemId,
|
||||
partsRemaining,
|
||||
partsCompleted,
|
||||
totalParts: downloadItemParts.length,
|
||||
itemProgress
|
||||
}
|
||||
data.itemProgress = itemProgress
|
||||
data.episodes = downloadItemParts.filter((dip) => dip.episode).map((dip) => dip.episode)
|
||||
|
||||
console.log('[download] Saving item update download payload', JSON.stringify(update))
|
||||
console.log('[download] Download Progress indicator data', JSON.stringify(data))
|
||||
|
||||
this.$set(this.itemDownloadingMap, update.id, update)
|
||||
|
||||
this.$store.commit('globals/addUpdateItemDownload', data)
|
||||
clickedIt() {
|
||||
if (this.isIos) return // TODO: Implement on iOS
|
||||
this.$router.push('/downloading')
|
||||
},
|
||||
onItemDownloadComplete(data) {
|
||||
console.log('DownloadProgressIndicator onItemDownloadComplete', JSON.stringify(data))
|
||||
@@ -85,11 +54,6 @@ export default {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.itemDownloadingMap[data.libraryItemId]) {
|
||||
delete this.itemDownloadingMap[data.libraryItemId]
|
||||
} else {
|
||||
console.warn('Item download complete but not found in item downloading map', data.libraryItemId)
|
||||
}
|
||||
if (!data.localLibraryItem) {
|
||||
this.$toast.error('Item download complete but failed to create library item')
|
||||
} else {
|
||||
@@ -103,15 +67,28 @@ export default {
|
||||
}
|
||||
|
||||
this.$store.commit('globals/removeItemDownload', data.libraryItemId)
|
||||
},
|
||||
onDownloadItem(downloadItem) {
|
||||
console.log('DownloadProgressIndicator onDownloadItem', JSON.stringify(downloadItem))
|
||||
|
||||
downloadItem.itemProgress = 0
|
||||
downloadItem.episodes = downloadItem.downloadItemParts.filter((dip) => dip.episode).map((dip) => dip.episode)
|
||||
|
||||
this.$store.commit('globals/addUpdateItemDownload', downloadItem)
|
||||
},
|
||||
onDownloadItemPartUpdate(itemPart) {
|
||||
this.$store.commit('globals/updateDownloadItemPart', itemPart)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.updateListener = AbsDownloader.addListener('onItemDownloadUpdate', (data) => this.onItemDownloadUpdate(data))
|
||||
this.downloadItemListener = AbsDownloader.addListener('onDownloadItem', (data) => this.onDownloadItem(data))
|
||||
this.itemPartUpdateListener = AbsDownloader.addListener('onDownloadItemPartUpdate', (data) => this.onDownloadItemPartUpdate(data))
|
||||
this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.updateListener) this.updateListener.remove()
|
||||
if (this.downloadItemListener) this.downloadItemListener.remove()
|
||||
if (this.completeListener) this.completeListener.remove()
|
||||
if (this.itemPartUpdateListener) this.itemPartUpdateListener.remove()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -147,6 +147,16 @@ export default {
|
||||
margin-top: -2px;
|
||||
margin-left: -2px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-lg {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-lg > div {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: -4px;
|
||||
margin-left: -4px;
|
||||
}
|
||||
.la-ball-spin-clockwise.la-2x {
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
|
||||
@@ -651,7 +651,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
@@ -705,7 +705,7 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||
@@ -720,12 +720,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.59;
|
||||
MARKETING_VERSION = 0.9.61;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -744,12 +744,12 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 16;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.59;
|
||||
MARKETING_VERSION = 0.9.61;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
@@ -768,7 +768,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
@@ -790,7 +790,7 @@
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||
MARKETING_VERSION = 1.0;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.AudiobookshelfUnitTests;
|
||||
|
||||
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 4,
|
||||
schemaVersion: 6,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
@@ -30,6 +30,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
indexCounter += 1
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 5) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding lockOrientation setting")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["lockOrientation"] = "NONE"
|
||||
}
|
||||
}
|
||||
if (oldSchemaVersion < 6) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)... Adding hapticFeedback setting")
|
||||
migration.enumerateObjects(ofType: DeviceSettings.className()) { oldObject, newObject in
|
||||
newObject?["hapticFeedback"] = "LIGHT"
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
Realm.Configuration.defaultConfiguration = configuration
|
||||
@@ -82,15 +94,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
backgroundCompletionHandler = completionHandler
|
||||
}
|
||||
|
||||
override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
|
||||
super.touchesBegan(touches, with: event)
|
||||
|
||||
let statusBarRect = self.window?.windowScene?.statusBarManager?.statusBarFrame
|
||||
guard let touchPoint = event?.allTouches?.first?.location(in: self.window) else { return }
|
||||
|
||||
if statusBarRect?.contains(touchPoint) ?? false {
|
||||
NotificationCenter.default.post(name: .capacitorStatusBarTapped, object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -22,11 +22,6 @@
|
||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||
<key>LSRequiresIPhoneOS</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>NSUserActivityTypes</key>
|
||||
<array>
|
||||
<string>INPlayMediaIntent</string>
|
||||
|
||||
@@ -2,5 +2,9 @@
|
||||
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||
<access origin="*" />
|
||||
|
||||
<feature name="CDVOrientation">
|
||||
<param name="ios-package" value="CDVOrientation"/>
|
||||
</feature>
|
||||
|
||||
|
||||
</widget>
|
||||
@@ -26,6 +26,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||
|
||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
||||
self.bridge?.webView?.scrollView.alwaysBounceVertical = false;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -236,11 +236,15 @@ public class AbsDatabase: CAPPlugin {
|
||||
let enableAltView = call.getBool("enableAltView") ?? false
|
||||
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
|
||||
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
|
||||
let lockOrientation = call.getString("lockOrientation") ?? "NONE"
|
||||
let hapticFeedback = call.getString("hapticFeedback") ?? "LIGHT"
|
||||
let settings = DeviceSettings()
|
||||
settings.disableAutoRewind = disableAutoRewind
|
||||
settings.enableAltView = enableAltView
|
||||
settings.jumpBackwardsTime = jumpBackwardsTime
|
||||
settings.jumpForwardTime = jumpForwardTime
|
||||
settings.lockOrientation = lockOrientation
|
||||
settings.hapticFeedback = hapticFeedback
|
||||
|
||||
Database.shared.setDeviceSettings(deviceSettings: settings)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user