mirror of
https://github.com/advplyr/audiobookshelf-app.git
synced 2026-08-26 21:34:05 +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 {
|
android {
|
||||||
|
|
||||||
|
|
||||||
|
namespace 'com.audiobookshelf.app'
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding true
|
viewBinding true
|
||||||
}
|
}
|
||||||
@@ -33,8 +35,8 @@ android {
|
|||||||
applicationId "com.audiobookshelf.app"
|
applicationId "com.audiobookshelf.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 89
|
versionCode 92
|
||||||
versionName "0.9.59-beta"
|
versionName "0.9.61-beta"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
@@ -78,6 +80,7 @@ configurations.all {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation project(':capacitor-android')
|
implementation project(':capacitor-android')
|
||||||
@@ -121,7 +124,7 @@ dependencies {
|
|||||||
implementation 'io.github.pilgr:paperdb:2.7.2'
|
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||||
|
|
||||||
// Simple Storage
|
// Simple Storage
|
||||||
implementation "com.anggrayudi:storage:0.14.0"
|
implementation "com.anggrayudi:storage:1.5.4"
|
||||||
|
|
||||||
// OK HTTP
|
// OK HTTP
|
||||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
android {
|
android {
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_1_8
|
sourceCompatibility JavaVersion.VERSION_11
|
||||||
targetCompatibility JavaVersion.VERSION_1_8
|
targetCompatibility JavaVersion.VERSION_11
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13,8 +13,8 @@ dependencies {
|
|||||||
implementation project(':capacitor-dialog')
|
implementation project(':capacitor-dialog')
|
||||||
implementation project(':capacitor-haptics')
|
implementation project(':capacitor-haptics')
|
||||||
implementation project(':capacitor-network')
|
implementation project(':capacitor-network')
|
||||||
|
implementation project(':capacitor-preferences')
|
||||||
implementation project(':capacitor-status-bar')
|
implementation project(':capacitor-status-bar')
|
||||||
implementation project(':capacitor-storage')
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:dist="http://schemas.android.com/apk/distribution"
|
xmlns:dist="http://schemas.android.com/apk/distribution"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
package="com.audiobookshelf.app"
|
|
||||||
android:installLocation="preferExternal" >
|
android:installLocation="preferExternal" >
|
||||||
|
|
||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
@@ -75,7 +74,7 @@
|
|||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="false">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
|||||||
@@ -16,11 +16,11 @@
|
|||||||
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
|
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/status-bar",
|
"pkg": "@capacitor/preferences",
|
||||||
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
|
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"pkg": "@capacitor/storage",
|
"pkg": "@capacitor/status-bar",
|
||||||
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
|
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ import android.util.Log
|
|||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import com.anggrayudi.storage.SimpleStorage
|
import com.anggrayudi.storage.SimpleStorage
|
||||||
import com.anggrayudi.storage.SimpleStorageHelper
|
import com.anggrayudi.storage.SimpleStorageHelper
|
||||||
import com.audiobookshelf.app.data.AbsDatabase
|
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||||
import com.audiobookshelf.app.data.DbManager
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
||||||
import com.audiobookshelf.app.plugins.AbsDownloader
|
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||||
@@ -51,11 +51,17 @@ class MainActivity : BridgeActivity() {
|
|||||||
// .detectLeakedClosableObjects()
|
// .detectLeakedClosableObjects()
|
||||||
// .penaltyLog()
|
// .penaltyLog()
|
||||||
// .build())
|
// .build())
|
||||||
|
DbManager.initialize(applicationContext)
|
||||||
|
|
||||||
|
registerPlugin(AbsAudioPlayer::class.java)
|
||||||
|
registerPlugin(AbsDownloader::class.java)
|
||||||
|
registerPlugin(AbsFileSystem::class.java)
|
||||||
|
registerPlugin(AbsDatabase::class.java)
|
||||||
|
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
Log.d(tag, "onCreate")
|
Log.d(tag, "onCreate")
|
||||||
|
|
||||||
DbManager.initialize(applicationContext)
|
|
||||||
|
|
||||||
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
val permission = ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
if (permission != PackageManager.PERMISSION_GRANTED) {
|
if (permission != PackageManager.PERMISSION_GRANTED) {
|
||||||
@@ -63,11 +69,6 @@ class MainActivity : BridgeActivity() {
|
|||||||
PERMISSIONS_ALL,
|
PERMISSIONS_ALL,
|
||||||
REQUEST_PERMISSIONS)
|
REQUEST_PERMISSIONS)
|
||||||
}
|
}
|
||||||
|
|
||||||
registerPlugin(AbsAudioPlayer::class.java)
|
|
||||||
registerPlugin(AbsDownloader::class.java)
|
|
||||||
registerPlugin(AbsFileSystem::class.java)
|
|
||||||
registerPlugin(AbsDatabase::class.java)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
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
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.audiobookshelf.app.media.MediaManager
|
||||||
import com.fasterxml.jackson.annotation.*
|
import com.fasterxml.jackson.annotation.*
|
||||||
|
|
||||||
// This auto-detects whether it is a Book or Podcast
|
// This auto-detects whether it is a Book or Podcast
|
||||||
@@ -50,7 +52,8 @@ class Podcast(
|
|||||||
// Add new episodes
|
// Add new episodes
|
||||||
audioTracks.forEach { at ->
|
audioTracks.forEach { at ->
|
||||||
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||||
val newEpisode = PodcastEpisode("local_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)
|
episodes?.add(newEpisode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,7 +66,8 @@ class Podcast(
|
|||||||
}
|
}
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun addAudioTrack(audioTrack:AudioTrack) {
|
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)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -82,9 +86,17 @@ class Podcast(
|
|||||||
index++
|
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
|
@JsonIgnore
|
||||||
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
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)
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
var index = 1
|
var index = 1
|
||||||
@@ -95,10 +107,14 @@ class Podcast(
|
|||||||
return newEpisode
|
return newEpisode
|
||||||
}
|
}
|
||||||
|
|
||||||
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getLocalCopy(): Podcast {
|
fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
|
||||||
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
|
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>) {
|
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
||||||
tracks = audioTracks
|
tracks = audioTracks
|
||||||
tracks?.sortBy { it.index }
|
tracks?.sortBy { it.index }
|
||||||
// TODO: Is it necessary to calculate this each time? check if can remove safely
|
|
||||||
var totalDuration = 0.0
|
var totalDuration = 0.0
|
||||||
|
var currentStartOffset = 0.0
|
||||||
tracks?.forEach {
|
tracks?.forEach {
|
||||||
totalDuration += it.duration
|
totalDuration += it.duration
|
||||||
|
it.startOffset = currentStartOffset
|
||||||
|
currentStartOffset += it.duration
|
||||||
}
|
}
|
||||||
duration = totalDuration
|
duration = totalDuration
|
||||||
}
|
}
|
||||||
@@ -158,7 +177,6 @@ class Book(
|
|||||||
}
|
}
|
||||||
duration = totalDuration
|
duration = totalDuration
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getLocalCopy(): Book {
|
override fun getLocalCopy(): Book {
|
||||||
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
|
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(),null,null, 0)
|
||||||
@@ -228,21 +246,32 @@ data class PodcastEpisode(
|
|||||||
var title:String?,
|
var title:String?,
|
||||||
var subtitle:String?,
|
var subtitle:String?,
|
||||||
var description:String?,
|
var description:String?,
|
||||||
|
var pubDate:String?,
|
||||||
|
var publishedAt:Long?,
|
||||||
var audioFile:AudioFile?,
|
var audioFile:AudioFile?,
|
||||||
var audioTrack:AudioTrack?,
|
var audioTrack:AudioTrack?,
|
||||||
var duration:Double?,
|
var duration:Double?,
|
||||||
var size:Long?,
|
var size:Long?,
|
||||||
var serverEpisodeId:String? // For local podcasts to match with server podcasts
|
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
|
||||||
|
var localEpisodeId:String? // For Android Auto server episodes with local copy
|
||||||
) {
|
) {
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||||
val coverUri = if(libraryItem is LocalLibraryItem) {
|
val coverUri = if (libraryItem is LocalLibraryItem) {
|
||||||
libraryItem.getCoverUri()
|
libraryItem.getCoverUri()
|
||||||
} else {
|
} else {
|
||||||
(libraryItem as LibraryItem).getCoverUri()
|
(libraryItem as LibraryItem).getCoverUri()
|
||||||
}
|
}
|
||||||
|
|
||||||
val extras = Bundle()
|
val extras = Bundle()
|
||||||
|
|
||||||
|
if (localEpisodeId != null) {
|
||||||
|
extras.putLong(
|
||||||
|
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||||
|
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (progress != null) {
|
if (progress != null) {
|
||||||
if (progress.isFinished) {
|
if (progress.isFinished) {
|
||||||
extras.putInt(
|
extras.putInt(
|
||||||
@@ -264,22 +293,21 @@ data class PodcastEpisode(
|
|||||||
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// return MediaMetadataCompat.Builder().apply {
|
|
||||||
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
|
||||||
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
val mediaId = localEpisodeId ?: id
|
||||||
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||||
// putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, podcast.metadata.getAuthorDisplayName())
|
.setMediaId(mediaId)
|
||||||
// putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, coverUri.toString())
|
|
||||||
//
|
|
||||||
// }.build()
|
|
||||||
val libraryItemDescription = libraryItem.getMediaDescription(null)
|
|
||||||
return MediaDescriptionCompat.Builder()
|
|
||||||
.setMediaId(id)
|
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
.setIconUri(coverUri)
|
.setIconUri(coverUri)
|
||||||
.setSubtitle(libraryItemDescription.title)
|
.setSubtitle(libraryItemDescription.title)
|
||||||
.setExtras(extras)
|
.setExtras(extras)
|
||||||
.build()
|
|
||||||
|
libraryItemDescription.iconBitmap?.let {
|
||||||
|
mediaDescriptionBuilder.setIconBitmap(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaDescriptionBuilder.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,36 +357,6 @@ data class Folder(
|
|||||||
var fullPath:String
|
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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class BookChapter(
|
data class BookChapter(
|
||||||
var id:Int,
|
var id:Int,
|
||||||
@@ -372,26 +370,14 @@ data class BookChapter(
|
|||||||
val endMs get() = (end * 1000L).toLong()
|
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)
|
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
|
||||||
@JsonSubTypes(
|
@JsonSubTypes(
|
||||||
JsonSubTypes.Type(MediaProgress::class),
|
JsonSubTypes.Type(MediaProgress::class),
|
||||||
JsonSubTypes.Type(LocalMediaProgress::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
|
// Helper class
|
||||||
data class LibraryItemWithEpisode(
|
data class LibraryItemWithEpisode(
|
||||||
|
|||||||
@@ -1,11 +1,25 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import android.util.Log
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.annotation.JsonSubTypes
|
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||||
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
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(
|
data class ServerConnectionConfig(
|
||||||
var id:String,
|
var id:String,
|
||||||
var index:Int,
|
var index:Int,
|
||||||
@@ -17,46 +31,6 @@ data class ServerConnectionConfig(
|
|||||||
var customHeaders:Map<String, String>?
|
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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class LocalFile(
|
data class LocalFile(
|
||||||
var id:String,
|
var id:String,
|
||||||
@@ -95,7 +69,7 @@ data class LocalFolder(
|
|||||||
)
|
)
|
||||||
open class LibraryItemWrapper(var id:String) {
|
open class LibraryItemWrapper(var id:String) {
|
||||||
@JsonIgnore
|
@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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -114,3 +88,80 @@ data class PlayItemRequestPayload(
|
|||||||
var forceTranscode:Boolean,
|
var forceTranscode:Boolean,
|
||||||
var deviceInfo:DeviceInfo
|
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
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
@@ -29,7 +30,8 @@ class LibraryItem(
|
|||||||
var mediaType:String,
|
var mediaType:String,
|
||||||
var media:MediaType,
|
var media:MediaType,
|
||||||
var libraryFiles:MutableList<LibraryFile>?,
|
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) {
|
) : LibraryItemWrapper(id) {
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val title get() = media.metadata.title
|
val title get() = media.metadata.title
|
||||||
@@ -55,8 +57,16 @@ class LibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context?): MediaDescriptionCompat {
|
||||||
val extras = Bundle()
|
val extras = Bundle()
|
||||||
|
|
||||||
|
if (localLibraryItemId != null) {
|
||||||
|
extras.putLong(
|
||||||
|
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||||
|
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (progress != null) {
|
if (progress != null) {
|
||||||
if (progress.isFinished) {
|
if (progress.isFinished) {
|
||||||
extras.putInt(
|
extras.putInt(
|
||||||
@@ -79,8 +89,9 @@ class LibraryItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val mediaId = localLibraryItemId ?: id
|
||||||
return MediaDescriptionCompat.Builder()
|
return MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(id)
|
.setMediaId(mediaId)
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
.setIconUri(getCoverUri())
|
.setIconUri(getCoverUri())
|
||||||
.setSubtitle(authorName)
|
.setSubtitle(authorName)
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.provider.MediaStore
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.media.utils.MediaConstants
|
import androidx.media.utils.MediaConstants
|
||||||
@@ -9,6 +14,7 @@ import com.audiobookshelf.app.R
|
|||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.audiobookshelf.app.player.PLAYMETHOD_LOCAL
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
@@ -96,10 +102,26 @@ class LocalLibraryItem(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
override fun getMediaDescription(progress:MediaProgressWrapper?): MediaDescriptionCompat {
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context?): MediaDescriptionCompat {
|
||||||
val coverUri = getCoverUri()
|
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()
|
val extras = Bundle()
|
||||||
|
extras.putLong(
|
||||||
|
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||||
|
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||||
|
)
|
||||||
if (progress != null) {
|
if (progress != null) {
|
||||||
if (progress.isFinished) {
|
if (progress.isFinished) {
|
||||||
extras.putInt(
|
extras.putInt(
|
||||||
@@ -122,12 +144,17 @@ class LocalLibraryItem(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return MediaDescriptionCompat.Builder()
|
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||||
.setMediaId(id)
|
.setMediaId(id)
|
||||||
.setTitle(title)
|
.setTitle(title)
|
||||||
.setIconUri(coverUri)
|
.setIconUri(coverUri)
|
||||||
.setSubtitle(authorName)
|
.setSubtitle(authorName)
|
||||||
.setExtras(extras)
|
.setExtras(extras)
|
||||||
.build()
|
|
||||||
|
bitmap?.let {
|
||||||
|
mediaDescriptionBuilder.setIconBitmap(bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaDescriptionBuilder.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
@@ -11,7 +12,7 @@ class LocalMediaProgress(
|
|||||||
var localEpisodeId:String?,
|
var localEpisodeId:String?,
|
||||||
var duration:Double,
|
var duration:Double,
|
||||||
progress:Double, // 0 to 1
|
progress:Double, // 0 to 1
|
||||||
var currentTime:Double,
|
currentTime:Double,
|
||||||
isFinished:Boolean,
|
isFinished:Boolean,
|
||||||
var lastUpdate:Long,
|
var lastUpdate:Long,
|
||||||
var startedAt:Long,
|
var startedAt:Long,
|
||||||
@@ -22,9 +23,16 @@ class LocalMediaProgress(
|
|||||||
var serverUserId:String?,
|
var serverUserId:String?,
|
||||||
var libraryItemId:String?,
|
var libraryItemId:String?,
|
||||||
var episodeId:String?
|
var episodeId:String?
|
||||||
) : MediaProgressWrapper(isFinished, progress) {
|
) : MediaProgressWrapper(isFinished, currentTime, progress) {
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
|
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
|
@JsonIgnore
|
||||||
fun updateIsFinished(finished:Boolean) {
|
fun updateIsFinished(finished:Boolean) {
|
||||||
@@ -41,8 +49,7 @@ class LocalMediaProgress(
|
|||||||
fun updateFromPlaybackSession(playbackSession:PlaybackSession) {
|
fun updateFromPlaybackSession(playbackSession:PlaybackSession) {
|
||||||
currentTime = playbackSession.currentTime
|
currentTime = playbackSession.currentTime
|
||||||
progress = playbackSession.progress
|
progress = playbackSession.progress
|
||||||
lastUpdate = System.currentTimeMillis()
|
lastUpdate = playbackSession.updatedAt
|
||||||
|
|
||||||
isFinished = playbackSession.progress >= 0.99
|
isFinished = playbackSession.progress >= 0.99
|
||||||
finishedAt = if (isFinished) lastUpdate else null
|
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
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
import android.support.v4.media.MediaMetadataCompat
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
import com.audiobookshelf.app.R
|
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
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.MediaInfo
|
||||||
import com.google.android.gms.cast.MediaQueueItem
|
import com.google.android.gms.cast.MediaQueueItem
|
||||||
import com.google.android.gms.common.images.WebImage
|
import com.google.android.gms.common.images.WebImage
|
||||||
|
import com.audiobookshelf.app.player.*
|
||||||
// TODO: enum or something in kotlin?
|
|
||||||
val PLAYMETHOD_DIRECTPLAY = 0
|
|
||||||
val PLAYMETHOD_DIRECTSTREAM = 1
|
|
||||||
val PLAYMETHOD_TRANSCODE = 2
|
|
||||||
val PLAYMETHOD_LOCAL = 3
|
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
class PlaybackSession(
|
class PlaybackSession(
|
||||||
@@ -53,17 +51,21 @@ class PlaybackSession(
|
|||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
|
val isLocal get() = playMethod == PLAYMETHOD_LOCAL
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
|
val isPodcastEpisode get() = mediaType == "podcast"
|
||||||
|
@get:JsonIgnore
|
||||||
val currentTimeMs get() = (currentTime * 1000L).toLong()
|
val currentTimeMs get() = (currentTime * 1000L).toLong()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
|
val totalDurationMs get() = (getTotalDuration() * 1000L).toLong()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val localLibraryItemId get() = localLibraryItem?.id ?: ""
|
val localLibraryItemId get() = localLibraryItem?.id ?: ""
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val localMediaProgressId get() = if (episodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
val localMediaProgressId get() = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val progress get() = currentTime / getTotalDuration()
|
val progress get() = currentTime / getTotalDuration()
|
||||||
@get:JsonIgnore
|
@get:JsonIgnore
|
||||||
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
|
val isLocalLibraryItemOnly get() = localLibraryItemId != "" && libraryItemId == null
|
||||||
|
@get:JsonIgnore
|
||||||
|
val mediaItemId get() = if (isLocalLibraryItemOnly) localMediaProgressId else if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCurrentTrackIndex():Int {
|
fun getCurrentTrackIndex():Int {
|
||||||
@@ -76,12 +78,41 @@ class PlaybackSession(
|
|||||||
return audioTracks.size - 1
|
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
|
@JsonIgnore
|
||||||
fun getChapterForTime(time:Long):BookChapter? {
|
fun getChapterForTime(time:Long):BookChapter? {
|
||||||
if (chapters.isEmpty()) return null
|
if (chapters.isEmpty()) return null
|
||||||
return chapters.find { time >= it.startMs && it.endMs > time}
|
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
|
@JsonIgnore
|
||||||
fun getCurrentTrackTimeMs():Long {
|
fun getCurrentTrackTimeMs():Long {
|
||||||
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||||
@@ -105,9 +136,9 @@ class PlaybackSession(
|
|||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getCoverUri(): Uri {
|
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}")
|
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,13 +149,33 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnore
|
@JsonIgnore
|
||||||
fun getMediaMetadataCompat(): MediaMetadataCompat {
|
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||||
val metadataBuilder = MediaMetadataCompat.Builder()
|
val metadataBuilder = MediaMetadataCompat.Builder()
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, 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_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()
|
return metadataBuilder.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +187,9 @@ class PlaybackSession(
|
|||||||
.setArtist(displayAuthor)
|
.setArtist(displayAuthor)
|
||||||
.setAlbumArtist(displayAuthor)
|
.setAlbumArtist(displayAuthor)
|
||||||
.setSubtitle(displayAuthor)
|
.setSubtitle(displayAuthor)
|
||||||
|
.setAlbumTitle(displayAuthor)
|
||||||
|
.setDescription(displayAuthor)
|
||||||
|
.setArtworkUri(getCoverUri())
|
||||||
|
|
||||||
val contentUri = this.getContentUri(audioTrack)
|
val contentUri = this.getContentUri(audioTrack)
|
||||||
metadataBuilder.setMediaUri(contentUri)
|
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_TITLE, displayTitle ?: "")
|
||||||
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_ARTIST, displayAuthor ?: "")
|
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.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)
|
castMetadata.putInt(com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER, audioTrack.index)
|
||||||
return castMetadata
|
return castMetadata
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package com.audiobookshelf.app.device
|
|||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
|
||||||
interface WidgetEventEmitter {
|
interface WidgetEventEmitter {
|
||||||
@@ -11,8 +12,8 @@ interface WidgetEventEmitter {
|
|||||||
object DeviceManager {
|
object DeviceManager {
|
||||||
const val tag = "DeviceManager"
|
const val tag = "DeviceManager"
|
||||||
|
|
||||||
val dbManager:DbManager = DbManager()
|
val dbManager: DbManager = DbManager()
|
||||||
var deviceData:DeviceData = dbManager.getDeviceData()
|
var deviceData: DeviceData = dbManager.getDeviceData()
|
||||||
var serverConnectionConfig: ServerConnectionConfig? = null
|
var serverConnectionConfig: ServerConnectionConfig? = null
|
||||||
|
|
||||||
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
||||||
@@ -20,12 +21,21 @@ object DeviceManager {
|
|||||||
val serverUserId get() = serverConnectionConfig?.userId ?: ""
|
val serverUserId get() = serverConnectionConfig?.userId ?: ""
|
||||||
val token get() = serverConnectionConfig?.token ?: ""
|
val token get() = serverConnectionConfig?.token ?: ""
|
||||||
val isConnectedToServer get() = serverConnectionConfig != null
|
val isConnectedToServer get() = serverConnectionConfig != null
|
||||||
val hasLastServerConnectionConfig get() = deviceData.getLastServerConnectionConfig() != null
|
|
||||||
|
|
||||||
var widgetUpdater:WidgetEventEmitter? = null
|
var widgetUpdater:WidgetEventEmitter? = null
|
||||||
|
|
||||||
init {
|
init {
|
||||||
Log.d(tag, "Device Manager Singleton invoked")
|
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 {
|
fun getBase64Id(id:String):String {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import com.arthenica.ffmpegkit.FFmpegKitConfig
|
|||||||
import com.arthenica.ffmpegkit.FFprobeKit
|
import com.arthenica.ffmpegkit.FFprobeKit
|
||||||
import com.arthenica.ffmpegkit.Level
|
import com.arthenica.ffmpegkit.Level
|
||||||
import com.audiobookshelf.app.data.*
|
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.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.fasterxml.jackson.module.kotlin.readValue
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
@@ -18,7 +18,7 @@ import org.json.JSONException
|
|||||||
|
|
||||||
class FolderScanner(var ctx: Context) {
|
class FolderScanner(var ctx: Context) {
|
||||||
private val tag = "FolderScanner"
|
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?)
|
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}")
|
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 existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
||||||
|
|
||||||
val result = scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)
|
when (scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)) {
|
||||||
|
ItemScanResult.REMOVED -> mediaItemsRemoved++
|
||||||
if (result == ItemScanResult.REMOVED) mediaItemsRemoved++
|
ItemScanResult.UPDATED -> mediaItemsUpdated++
|
||||||
else if (result == ItemScanResult.UPDATED) mediaItemsUpdated++
|
ItemScanResult.ADDED -> mediaItemsAdded++
|
||||||
else if (result == ItemScanResult.ADDED) mediaItemsAdded++
|
else -> mediaItemsUpToDate++
|
||||||
else mediaItemsUpToDate++
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Log.d(tag, "Folder $${localFolder.name} scan Results: $mediaItemsAdded Added | $mediaItemsUpdated Updated | $mediaItemsRemoved Removed | $mediaItemsUpToDate Up-to-date")
|
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) {
|
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)
|
FolderScanResult(mediaItemsAdded, mediaItemsUpdated, mediaItemsRemoved, mediaItemsUpToDate, localFolder, folderLibraryItems)
|
||||||
} else {
|
} else {
|
||||||
Log.d(tag, "No Media Items to save")
|
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 itemFolderName = itemFolder.name ?: ""
|
||||||
val itemId = getLocalLibraryItemId(itemFolder.id)
|
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}")
|
Log.d(tag, "File attributes Id:${localFileId}|ContentUrl:${localFile.contentUrl}|isDownloadsDocument:${file.isDownloadsDocument}")
|
||||||
|
|
||||||
if (isAudio) {
|
if (isAudio) {
|
||||||
var audioTrackToAdd:AudioTrack? = null
|
val audioTrackToAdd:AudioTrack?
|
||||||
|
|
||||||
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
val existingAudioTrack = existingAudioTracks.find { eat -> eat.localFileId == localFileId }
|
||||||
if (existingAudioTrack != null) { // Update existing audio track
|
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
|
// 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 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 itemFolderId = ""
|
||||||
var itemFolderUrl = ""
|
var itemFolderUrl = ""
|
||||||
var itemFolderBasePath = ""
|
var itemFolderBasePath = ""
|
||||||
var itemFolderAbsolutePath = ""
|
var itemFolderAbsolutePath = ""
|
||||||
foldersFound.forEach {
|
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
|
itemFolderId = it.id
|
||||||
itemFolderUrl = it.uri.toString()
|
itemFolderUrl = it.uri.toString()
|
||||||
itemFolderBasePath = it.getBasePath(ctx)
|
itemFolderBasePath = it.getBasePath(ctx)
|
||||||
@@ -238,13 +240,13 @@ class FolderScanner(var ctx: Context) {
|
|||||||
|
|
||||||
if (itemFolderUrl == "") {
|
if (itemFolderUrl == "") {
|
||||||
Log.d(tag, "scanDownloadItem failed to find media folder")
|
Log.d(tag, "scanDownloadItem failed to find media folder")
|
||||||
return null
|
return cb(null)
|
||||||
}
|
}
|
||||||
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
val df: DocumentFile? = DocumentFileCompat.fromUri(ctx, Uri.parse(itemFolderUrl))
|
||||||
|
|
||||||
if (df == null) {
|
if (df == null) {
|
||||||
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
Log.e(tag, "Folder Doc File Invalid ${downloadItem.itemFolderPath}")
|
||||||
return null
|
return cb(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
val localLibraryItemId = getLocalLibraryItemId(itemFolderId)
|
||||||
@@ -256,7 +258,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
Log.d(tag, "scanDownloadItem ${filesFound.size} files found in ${downloadItem.itemFolderPath}")
|
||||||
|
|
||||||
var localEpisodeId:String? = null
|
var localEpisodeId:String? = null
|
||||||
var localLibraryItem:LocalLibraryItem? = null
|
var localLibraryItem:LocalLibraryItem?
|
||||||
if (downloadItem.mediaType == "book") {
|
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)
|
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 {
|
} else {
|
||||||
@@ -268,53 +270,50 @@ class FolderScanner(var ctx: Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
val audioTracks:MutableList<AudioTrack> = mutableListOf()
|
||||||
|
|
||||||
filesFound.forEach { docFile ->
|
filesFound.forEach { docFile ->
|
||||||
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
val itemPart = downloadItem.downloadItemParts.find { itemPart ->
|
||||||
itemPart.filename == docFile.name
|
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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()) {
|
if (audioTracks.isEmpty()) {
|
||||||
Log.d(tag, "scanDownloadItem did not find any audio tracks in folder for ${downloadItem.itemFolderPath}")
|
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
|
// For books sort audio tracks then set
|
||||||
@@ -364,7 +363,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
|
|
||||||
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem)
|
||||||
|
|
||||||
return downloadItemScanResult
|
cb(downloadItemScanResult)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
fun scanLocalLibraryItem(localLibraryItem:LocalLibraryItem, forceAudioProbe:Boolean):LocalLibraryItemScanResult? {
|
||||||
@@ -401,7 +400,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
localLibraryItem.localFiles.removeIf { it.id == localFileId }
|
localLibraryItem.localFiles.removeIf { it.id == localFileId }
|
||||||
|
|
||||||
if (existingAudioTracks.find { it.localFileId == localFileId } != null) {
|
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)
|
localLibraryItem.media.removeAudioTrack(localFileId)
|
||||||
}
|
}
|
||||||
wasUpdated = true
|
wasUpdated = true
|
||||||
@@ -464,7 +463,7 @@ class FolderScanner(var ctx: Context) {
|
|||||||
return LocalLibraryItemScanResult(wasUpdated, localLibraryItem)
|
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")
|
val session = FFprobeKit.execute("-i \"${absolutePath}\" -print_format json -show_format -show_streams -select_streams a -show_chapters -loglevel quiet")
|
||||||
|
|
||||||
var probeObject:JSObject? = null
|
var probeObject:JSObject? = null
|
||||||
@@ -475,13 +474,13 @@ class FolderScanner(var ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Log.d(tag, "FFprobe output $probeObject")
|
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")
|
Log.d(tag, "probeAudioFile Probe audio file $absolutePath failed or invalid")
|
||||||
return null
|
null
|
||||||
} else {
|
} else {
|
||||||
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
val audioProbeResult = jacksonMapper.readValue<AudioProbeResult>(session.output)
|
||||||
Log.d(tag, "Probe Result DATA ${audioProbeResult.duration} | ${audioProbeResult.size} | ${audioProbeResult.title} | ${audioProbeResult.artist}")
|
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.content.Context
|
||||||
import android.util.Log
|
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 io.paperdb.Paper
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
@@ -10,7 +11,7 @@ class DbManager {
|
|||||||
val tag = "DbManager"
|
val tag = "DbManager"
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var isDbInitialized = false
|
private var isDbInitialized = false
|
||||||
|
|
||||||
fun initialize(ctx: Context) {
|
fun initialize(ctx: Context) {
|
||||||
if (isDbInitialized) return
|
if (isDbInitialized) return
|
||||||
@@ -23,14 +24,14 @@ class DbManager {
|
|||||||
fun getDeviceData(): DeviceData {
|
fun getDeviceData(): DeviceData {
|
||||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
|
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)
|
Paper.book("device").write("data", deviceData)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
|
fun getLocalLibraryItems(mediaType:String? = null):MutableList<LocalLibraryItem> {
|
||||||
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
val localLibraryItems:MutableList<LocalLibraryItem> = mutableListOf()
|
||||||
Paper.book("localLibraryItems").allKeys.forEach {
|
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)) {
|
if (localLibraryItem != null && (mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)) {
|
||||||
localLibraryItems.add(localLibraryItem)
|
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 }
|
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalLibraryItem(localLibraryItemId:String):LocalLibraryItem? {
|
fun getLocalLibraryItem(localLibraryItemId:String): LocalLibraryItem? {
|
||||||
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String):LibraryItemWithEpisode? {
|
fun getLocalLibraryItemWithEpisode(podcastEpisodeId:String): LibraryItemWithEpisode? {
|
||||||
var podcastEpisode:PodcastEpisode? = null
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
|
val localLibraryItem = getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||||
val podcast = localLibraryItem.media as Podcast
|
val podcast = localLibraryItem.media as Podcast
|
||||||
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
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)
|
Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveLocalFolder(localFolder:LocalFolder) {
|
fun saveLocalFolder(localFolder: LocalFolder) {
|
||||||
Paper.book("localFolders").write(localFolder.id,localFolder)
|
Paper.book("localFolders").write(localFolder.id,localFolder)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getLocalFolder(folderId:String):LocalFolder? {
|
fun getLocalFolder(folderId:String): LocalFolder? {
|
||||||
return Paper.book("localFolders").read(folderId)
|
return Paper.book("localFolders").read(folderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ class DbManager {
|
|||||||
Paper.book("localFolders").delete(folderId)
|
Paper.book("localFolders").delete(folderId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveDownloadItem(downloadItem: AbsDownloader.DownloadItem) {
|
fun saveDownloadItem(downloadItem: DownloadItem) {
|
||||||
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,21 +116,21 @@ class DbManager {
|
|||||||
Paper.book("downloadItems").delete(downloadItemId)
|
Paper.book("downloadItems").delete(downloadItemId)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getDownloadItems():List<AbsDownloader.DownloadItem> {
|
fun getDownloadItems():List<DownloadItem> {
|
||||||
val downloadItems:MutableList<AbsDownloader.DownloadItem> = mutableListOf()
|
val downloadItems:MutableList<DownloadItem> = mutableListOf()
|
||||||
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
||||||
Paper.book("downloadItems").read<AbsDownloader.DownloadItem>(downloadItemId)?.let {
|
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let {
|
||||||
downloadItems.add(it)
|
downloadItems.add(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return downloadItems
|
return downloadItems
|
||||||
}
|
}
|
||||||
|
|
||||||
fun saveLocalMediaProgress(mediaProgress:LocalMediaProgress) {
|
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
||||||
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
|
Paper.book("localMediaProgress").write(mediaProgress.id,mediaProgress)
|
||||||
}
|
}
|
||||||
// For books this will just be the localLibraryItemId for podcast episodes this will be "{localLibraryItemId}-{episodeId}"
|
// 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)
|
return Paper.book("localMediaProgress").read(localMediaProgressId)
|
||||||
}
|
}
|
||||||
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
|
fun getAllLocalMediaProgress():List<LocalMediaProgress> {
|
||||||
@@ -154,7 +155,7 @@ class DbManager {
|
|||||||
val localLibraryItems = getLocalLibraryItems()
|
val localLibraryItems = getLocalLibraryItems()
|
||||||
|
|
||||||
localLibraryItems.forEach { lli ->
|
localLibraryItems.forEach { lli ->
|
||||||
var hasUpates = false
|
var hasUpdates = false
|
||||||
|
|
||||||
// Check local files
|
// Check local files
|
||||||
lli.localFiles = lli.localFiles.filter { localFile ->
|
lli.localFiles = lli.localFiles.filter { localFile ->
|
||||||
@@ -162,7 +163,7 @@ class DbManager {
|
|||||||
val file = File(localFile.absolutePath)
|
val file = File(localFile.absolutePath)
|
||||||
if (!file.exists()) {
|
if (!file.exists()) {
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
|
Log.d(tag, "cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}")
|
||||||
hasUpates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
file.exists()
|
file.exists()
|
||||||
} as MutableList<LocalFile>
|
} as MutableList<LocalFile>
|
||||||
@@ -173,7 +174,7 @@ class DbManager {
|
|||||||
podcast.episodes = podcast.episodes?.filter { ep ->
|
podcast.episodes = podcast.episodes?.filter { ep ->
|
||||||
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
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}")
|
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
|
ep.audioTrack != null && lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||||
} as MutableList<PodcastEpisode>
|
} as MutableList<PodcastEpisode>
|
||||||
@@ -182,7 +183,7 @@ class DbManager {
|
|||||||
book.tracks = book.tracks?.filter { track ->
|
book.tracks = book.tracks?.filter { track ->
|
||||||
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
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}")
|
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
|
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||||
} as MutableList<AudioTrack>
|
} as MutableList<AudioTrack>
|
||||||
@@ -196,11 +197,11 @@ class DbManager {
|
|||||||
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
|
Log.d(tag, "cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}")
|
||||||
lli.coverAbsolutePath = null
|
lli.coverAbsolutePath = null
|
||||||
lli.coverContentUrl = null
|
lli.coverContentUrl = null
|
||||||
hasUpates = true
|
hasUpdates = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasUpates) {
|
if (hasUpdates) {
|
||||||
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
||||||
Paper.book("localLibraryItems").write(lli.id, lli)
|
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)
|
Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession)
|
||||||
}
|
}
|
||||||
fun getLocalPlaybackSession(playbackSessionId:String):PlaybackSession? {
|
fun getLocalPlaybackSession(playbackSessionId:String): PlaybackSession? {
|
||||||
return Paper.book("localPlaybackSession").read(playbackSessionId)
|
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.content.Context
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
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 java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
const val SLEEP_EXTENSION_TIME = 900000L // 15m
|
class SleepTimerManager constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||||
|
|
||||||
class SleepTimerManager constructor(val playerNotificationService:PlayerNotificationService) {
|
|
||||||
private val tag = "SleepTimerManager"
|
private val tag = "SleepTimerManager"
|
||||||
|
|
||||||
private var sleepTimerTask:TimerTask? = null
|
private var sleepTimerTask:TimerTask? = null
|
||||||
@@ -18,7 +19,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
private var sleepTimerEndTime:Long = 0L
|
private var sleepTimerEndTime:Long = 0L
|
||||||
private var sleepTimerLength:Long = 0L
|
private var sleepTimerLength:Long = 0L
|
||||||
private var sleepTimerElapsed:Long = 0L
|
private var sleepTimerElapsed:Long = 0L
|
||||||
private var sleepTimerExtensionTime:Long = 0L
|
|
||||||
private var sleepTimerFinishedAt:Long = 0L
|
private var sleepTimerFinishedAt:Long = 0L
|
||||||
|
|
||||||
private fun getCurrentTime():Long {
|
private fun getCurrentTime():Long {
|
||||||
@@ -57,7 +57,7 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||||
sleepTimerTask?.cancel()
|
sleepTimerTask?.cancel()
|
||||||
sleepTimerRunning = false
|
sleepTimerRunning = true
|
||||||
sleepTimerFinishedAt = 0L
|
sleepTimerFinishedAt = 0L
|
||||||
sleepTimerElapsed = 0L
|
sleepTimerElapsed = 0L
|
||||||
|
|
||||||
@@ -72,7 +72,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
}
|
}
|
||||||
sleepTimerEndTime = time
|
sleepTimerEndTime = time
|
||||||
sleepTimerLength = 0
|
sleepTimerLength = 0
|
||||||
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
|
|
||||||
|
|
||||||
if (sleepTimerEndTime > getDuration()) {
|
if (sleepTimerEndTime > getDuration()) {
|
||||||
sleepTimerEndTime = getDuration()
|
sleepTimerEndTime = getDuration()
|
||||||
@@ -80,7 +79,6 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
} else {
|
} else {
|
||||||
sleepTimerLength = time
|
sleepTimerLength = time
|
||||||
sleepTimerEndTime = 0L
|
sleepTimerEndTime = 0L
|
||||||
sleepTimerExtensionTime = time
|
|
||||||
|
|
||||||
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
if (sleepTimerLength + getCurrentTime() > getDuration()) {
|
||||||
sleepTimerLength = getDuration() - getCurrentTime()
|
sleepTimerLength = getDuration() - getCurrentTime()
|
||||||
@@ -89,9 +87,8 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
|
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||||
|
|
||||||
sleepTimerRunning = true
|
|
||||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Handler(Looper.getMainLooper()).post {
|
||||||
if (getIsPlaying()) {
|
if (getIsPlaying()) {
|
||||||
sleepTimerElapsed += 1000L
|
sleepTimerElapsed += 1000L
|
||||||
|
|
||||||
@@ -109,9 +106,9 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(getCurrentTime())
|
||||||
clearSleepTimer()
|
clearSleepTimer()
|
||||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||||
} else if (sleepTimeSecondsRemaining <= 30) {
|
} else if (sleepTimeSecondsRemaining <= 60) {
|
||||||
// Start fading out audio
|
// Start fading out audio
|
||||||
val volume = sleepTimeSecondsRemaining / 30F
|
val volume = sleepTimeSecondsRemaining / 60F
|
||||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||||
setVolume(volume)
|
setVolume(volume)
|
||||||
}
|
}
|
||||||
@@ -121,7 +118,7 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearSleepTimer() {
|
private fun clearSleepTimer() {
|
||||||
sleepTimerTask?.cancel()
|
sleepTimerTask?.cancel()
|
||||||
sleepTimerTask = null
|
sleepTimerTask = null
|
||||||
sleepTimerEndTime = 0
|
sleepTimerEndTime = 0
|
||||||
@@ -163,21 +160,40 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extendSleepTime() {
|
// Get the chapter end time for use in End of Chapter timers
|
||||||
if (!sleepTimerRunning) return
|
// if less than 2s remain in chapter then use the next chapter
|
||||||
setVolume(1F)
|
private fun getChapterEndTime():Long? {
|
||||||
if (sleepTimerEndTime == 0L) {
|
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
|
||||||
sleepTimerLength += sleepTimerExtensionTime
|
if (currentChapterEndTimeMs == null) {
|
||||||
if (sleepTimerLength + getCurrentTime() > getDuration()) sleepTimerLength = getDuration() - getCurrentTime()
|
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
|
||||||
} else {
|
return null
|
||||||
sleepTimerEndTime += sleepTimerExtensionTime
|
|
||||||
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 (!sleepTimerRunning) {
|
||||||
if (sleepTimerFinishedAt <= 0L) return
|
if (sleepTimerFinishedAt <= 0L) return
|
||||||
|
|
||||||
@@ -189,17 +205,35 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
|
// Set sleep timer
|
||||||
vibrate()
|
// When sleepTimerLength is 0 then use end of chapter/track time
|
||||||
setSleepTimer(newSleepTime, false)
|
if (sleepTimerLength == 0L) {
|
||||||
play()
|
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
|
return
|
||||||
}
|
}
|
||||||
// Only extend if within 30 seconds of finishing
|
|
||||||
val sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
|
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
|
||||||
if (sleepTimeRemaining <= 30) {
|
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
|
||||||
|
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
|
||||||
vibrate()
|
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")
|
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
checkShouldExtendSleepTimer()
|
checkShouldResetSleepTimer()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,4 +288,53 @@ class SleepTimerManager constructor(val playerNotificationService:PlayerNotifica
|
|||||||
setVolume(1F)
|
setVolume(1F)
|
||||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
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.resume
|
||||||
import kotlin.coroutines.suspendCoroutine
|
import kotlin.coroutines.suspendCoroutine
|
||||||
|
|
||||||
class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||||
val tag = "MediaManager"
|
val tag = "MediaManager"
|
||||||
|
|
||||||
var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
|
private var serverLibraryItems = mutableListOf<LibraryItem>() // Store all items here
|
||||||
var selectedLibraryItems = mutableListOf<LibraryItem>()
|
private var selectedLibraryItems = mutableListOf<LibraryItem>()
|
||||||
var selectedLibraryId = ""
|
private var selectedLibraryId = ""
|
||||||
|
|
||||||
var selectedPodcast:Podcast? = null
|
private var selectedPodcast:Podcast? = null
|
||||||
var selectedLibraryItemId:String? = null
|
private var selectedLibraryItemId:String? = null
|
||||||
var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
private var podcastEpisodeLibraryItemMap = mutableMapOf<String, LibraryItemWithEpisode>()
|
||||||
var serverLibraryCategories = listOf<LibraryCategory>()
|
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 serverItemsInProgress = listOf<ItemInProgress>()
|
||||||
var serverLibraries = listOf<Library>()
|
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 {
|
fun getIsLibrary(id:String) : Boolean {
|
||||||
return serverLibraries.find { it.id == id } != null
|
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()) {
|
if (serverItemsInProgress.isNotEmpty()) {
|
||||||
cb(serverItemsInProgress)
|
cb(serverItemsInProgress)
|
||||||
} else {
|
} 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")) {
|
if (libraryItemId.startsWith("local")) {
|
||||||
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
|
cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId))
|
||||||
} else {
|
} 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 ->
|
loadLibraryItem(libraryItemId) { libraryItemWrapper ->
|
||||||
Log.d(tag, "Loaded Podcast library item $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}")
|
Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}")
|
||||||
|
|
||||||
val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${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)
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
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 children = podcast.episodes?.map { podcastEpisode ->
|
||||||
|
|
||||||
val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id }
|
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)
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
children?.let { cb(children as MutableList) } ?: cb(mutableListOf())
|
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()) {
|
if (serverLibraries.isNotEmpty()) {
|
||||||
cb(serverLibraries)
|
cb(serverLibraries)
|
||||||
} else {
|
} 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
|
var successfulPing = false
|
||||||
suspendCoroutine<Boolean> { cont ->
|
suspendCoroutine { cont ->
|
||||||
apiHandler.pingServer(config) {
|
apiHandler.pingServer(config) {
|
||||||
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
|
Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it")
|
||||||
successfulPing = it
|
successfulPing = it
|
||||||
@@ -190,9 +199,9 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
return successfulPing
|
return successfulPing
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
|
private suspend fun authorize(config:ServerConnectionConfig) : MutableList<MediaProgress> {
|
||||||
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
|
var mediaProgress:MutableList<MediaProgress> = mutableListOf()
|
||||||
suspendCoroutine<MutableList<MediaProgress>> { cont ->
|
suspendCoroutine { cont ->
|
||||||
apiHandler.authorize(config) {
|
apiHandler.authorize(config) {
|
||||||
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
|
Log.d(tag, "authorize: Authorized server config ${config.address} result = $it")
|
||||||
if (!it.isNullOrEmpty()) {
|
if (!it.isNullOrEmpty()) {
|
||||||
@@ -204,7 +213,7 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
return mediaProgress
|
return mediaProgress
|
||||||
}
|
}
|
||||||
|
|
||||||
fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = runBlocking {
|
||||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||||
|
|
||||||
coroutineScope {
|
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) {
|
fun loadAndroidAutoItems(cb: () -> Unit) {
|
||||||
Log.d(tag, "Load android auto items")
|
Log.d(tag, "Load android auto items")
|
||||||
|
|
||||||
@@ -314,18 +340,18 @@ class MediaManager(var apiHandler: ApiHandler, var ctx: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
|
fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? {
|
||||||
if (id.startsWith("local")) {
|
return if (id.startsWith("local")) {
|
||||||
return DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id)
|
||||||
} else {
|
} else {
|
||||||
return podcastEpisodeLibraryItemMap[id]
|
podcastEpisodeLibraryItemMap[id]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getById(id:String) : LibraryItemWrapper? {
|
fun getById(id:String) : LibraryItemWrapper? {
|
||||||
if (id.startsWith("local")) {
|
return if (id.startsWith("local")) {
|
||||||
return DeviceManager.dbManager.getLocalLibraryItem(id)
|
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||||
} else {
|
} 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.LocalMediaProgress
|
||||||
import com.audiobookshelf.app.data.MediaProgress
|
import com.audiobookshelf.app.data.MediaProgress
|
||||||
import com.audiobookshelf.app.data.PlaybackSession
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
|
import com.audiobookshelf.app.data.Podcast
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.media.MediaEventManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.concurrent.schedule
|
import kotlin.concurrent.schedule
|
||||||
@@ -17,6 +19,12 @@ data class MediaProgressSyncData(
|
|||||||
var currentTime:Double // seconds
|
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) {
|
class MediaProgressSyncer(val playerNotificationService:PlayerNotificationService, private val apiHandler: ApiHandler) {
|
||||||
private val tag = "MediaProgressSync"
|
private val tag = "MediaProgressSync"
|
||||||
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
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 currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
||||||
var currentLocalMediaProgress: LocalMediaProgress? = null
|
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||||
|
|
||||||
val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||||
val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
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) {
|
if (listeningTimerRunning) {
|
||||||
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
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")
|
Log.d(tag, "Playback session changed, reset timer")
|
||||||
currentLocalMediaProgress = null
|
currentLocalMediaProgress = null
|
||||||
listeningTimerTask?.cancel()
|
listeningTimerTask?.cancel()
|
||||||
@@ -48,25 +56,32 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||||||
} else {
|
} else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if (playerNotificationService.getCurrentPlaybackSessionId() != currentSessionId) {
|
} else if (playbackSession.id != currentSessionId) {
|
||||||
currentLocalMediaProgress = null
|
currentLocalMediaProgress = null
|
||||||
}
|
}
|
||||||
|
|
||||||
listeningTimerRunning = true
|
listeningTimerRunning = true
|
||||||
lastSyncTime = System.currentTimeMillis()
|
lastSyncTime = System.currentTimeMillis()
|
||||||
Log.d(tag, "start: init last sync time $lastSyncTime")
|
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() {
|
Handler(Looper.getMainLooper()).post() {
|
||||||
if (playerNotificationService.currentPlayer.isPlaying) {
|
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 shouldSyncServer = PlayerNotificationService.isUnmeteredNetwork || System.currentTimeMillis() - lastSyncTime >= METERED_CONNECTION_SYNC_INTERVAL
|
||||||
|
|
||||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
if (currentTime > 0) {
|
if (currentTime > 0) {
|
||||||
sync(shouldSyncServer, currentTime) {
|
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||||
Log.d(tag, "Sync complete")
|
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) {
|
fun stop(shouldSync:Boolean? = true, cb: () -> Unit) {
|
||||||
if (!listeningTimerRunning) return
|
if (!listeningTimerRunning) {
|
||||||
|
reset()
|
||||||
|
return cb()
|
||||||
|
}
|
||||||
|
|
||||||
listeningTimerTask?.cancel()
|
listeningTimerTask?.cancel()
|
||||||
listeningTimerTask = null
|
listeningTimerTask = null
|
||||||
listeningTimerRunning = false
|
listeningTimerRunning = false
|
||||||
@@ -83,11 +109,19 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||||||
|
|
||||||
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
val currentTime = if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||||
if (currentTime > 0) { // Current time should always be > 0 on stop
|
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()
|
reset()
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
|
MediaEventManager.stopEvent(playbackSession, null)
|
||||||
|
}
|
||||||
|
|
||||||
reset()
|
reset()
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
@@ -95,6 +129,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||||||
|
|
||||||
fun pause(cb: () -> Unit) {
|
fun pause(cb: () -> Unit) {
|
||||||
if (!listeningTimerRunning) return
|
if (!listeningTimerRunning) return
|
||||||
|
|
||||||
listeningTimerTask?.cancel()
|
listeningTimerTask?.cancel()
|
||||||
listeningTimerTask = null
|
listeningTimerTask = null
|
||||||
listeningTimerRunning = false
|
listeningTimerRunning = false
|
||||||
@@ -103,39 +138,82 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||||||
|
|
||||||
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
if (currentTime > 0) { // Current time should always be > 0 on pause
|
if (currentTime > 0) { // Current time should always be > 0 on pause
|
||||||
sync(true, currentTime) {
|
sync(true, currentTime) { syncResult ->
|
||||||
lastSyncTime = 0L
|
lastSyncTime = 0L
|
||||||
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
|
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
|
|
||||||
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
|
MediaEventManager.pauseEvent(playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
|
||||||
cb()
|
cb()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
lastSyncTime = 0L
|
lastSyncTime = 0L
|
||||||
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
|
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
|
|
||||||
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
|
MediaEventManager.pauseEvent(playbackSession, null)
|
||||||
|
}
|
||||||
|
|
||||||
cb()
|
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) {
|
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||||
currentPlaybackSession?.let {
|
currentPlaybackSession?.let {
|
||||||
it.updatedAt = mediaProgress.lastUpdate
|
it.updatedAt = mediaProgress.lastUpdate
|
||||||
it.currentTime = mediaProgress.currentTime
|
it.currentTime = mediaProgress.currentTime
|
||||||
|
|
||||||
|
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||||
saveLocalProgress(it)
|
saveLocalProgress(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: () -> Unit) {
|
fun sync(shouldSyncServer:Boolean, currentTime:Double, cb: (SyncResult?) -> Unit) {
|
||||||
if (lastSyncTime <= 0) {
|
if (lastSyncTime <= 0) {
|
||||||
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
||||||
return
|
return cb(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
||||||
if (diffSinceLastSync < 1000L) {
|
if (diffSinceLastSync < 1000L) {
|
||||||
return cb()
|
return cb(null)
|
||||||
}
|
}
|
||||||
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||||
|
|
||||||
@@ -145,7 +223,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||||||
|
|
||||||
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||||
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
|
Log.e(tag, "Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}")
|
||||||
return cb()
|
return cb(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentIsLocal) {
|
if (currentIsLocal) {
|
||||||
@@ -158,11 +236,12 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||||||
// Local library item is linked to a server library item
|
// 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
|
// 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) {
|
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||||
apiHandler.sendLocalProgressSync(it) { syncSuccess ->
|
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||||
Log.d(
|
Log.d(
|
||||||
tag,
|
tag,
|
||||||
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
"Local progress sync data sent to server $currentDisplayTitle for time $currentTime"
|
||||||
)
|
)
|
||||||
|
|
||||||
if (syncSuccess) {
|
if (syncSuccess) {
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
playerNotificationService.alertSyncSuccess()
|
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")
|
Log.e(tag, "Local Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
|
||||||
}
|
}
|
||||||
|
|
||||||
cb()
|
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cb()
|
cb(SyncResult(false, null, null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (shouldSyncServer) {
|
} else if (shouldSyncServer) {
|
||||||
apiHandler.sendProgressSync(currentSessionId, syncData) {
|
Log.d(tag, "sync: currentSessionId=$currentSessionId")
|
||||||
if (it) {
|
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||||
|
if (syncSuccess) {
|
||||||
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
Log.d(tag, "Progress sync data sent to server $currentDisplayTitle for time $currentTime")
|
||||||
failedSyncs = 0
|
failedSyncs = 0
|
||||||
playerNotificationService.alertSyncSuccess()
|
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")
|
Log.e(tag, "Progress sync failed ($failedSyncs) to send to server $currentDisplayTitle for time $currentTime")
|
||||||
}
|
}
|
||||||
cb()
|
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cb()
|
cb(SyncResult(false, null, null))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,17 +290,18 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
|||||||
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
||||||
} else {
|
} else {
|
||||||
currentLocalMediaProgress = mediaProgress
|
currentLocalMediaProgress = mediaProgress
|
||||||
|
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
||||||
}
|
}
|
||||||
|
|
||||||
currentLocalMediaProgress?.let {
|
currentLocalMediaProgress?.let {
|
||||||
if (it.progress.isNaN()) {
|
if (it.progress.isNaN()) {
|
||||||
Log.e(tag, "Invalid progress on local media progress")
|
Log.e(tag, "Invalid progress on local media progress")
|
||||||
} else {
|
} else {
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
||||||
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||||
|
|
||||||
Log.d(tag, "Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%")
|
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.app.PendingIntent
|
||||||
import android.graphics.Bitmap
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
import android.support.v4.media.session.MediaControllerCompat
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.R
|
import com.audiobookshelf.app.R
|
||||||
import com.bumptech.glide.Glide
|
import com.bumptech.glide.Glide
|
||||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
|
||||||
import com.bumptech.glide.request.RequestOptions
|
|
||||||
import com.google.android.exoplayer2.Player
|
import com.google.android.exoplayer2.Player
|
||||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
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"
|
private val tag = "MediaDescriptionAdapter"
|
||||||
|
|
||||||
var currentIconUri: Uri? = null
|
private var currentIconUri: Uri? = null
|
||||||
var currentBitmap: Bitmap? = null
|
private var currentBitmap: Bitmap? = null
|
||||||
|
|
||||||
private val serviceJob = SupervisorJob()
|
private val serviceJob = SupervisorJob()
|
||||||
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
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.
|
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||||
currentIconUri = albumArtUri
|
currentIconUri = albumArtUri
|
||||||
Log.d(tag, "ART $currentIconUri")
|
Log.d(tag, "ART $currentIconUri")
|
||||||
serviceScope.launch {
|
|
||||||
currentBitmap = albumArtUri?.let {
|
if (currentIconUri.toString().startsWith("content://")) {
|
||||||
resolveUriAsBitmap(it)
|
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 {
|
} else {
|
||||||
currentBitmap
|
currentBitmap
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,10 @@ class BrowseTree(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* get uri to drawable or any other resource type if u wish
|
* get uri to drawable or any other resource type if u wish
|
||||||
* @param context - context
|
|
||||||
* @param drawableId - drawable res id
|
* @param drawableId - drawable res id
|
||||||
* @return - uri
|
* @return - uri
|
||||||
*/
|
*/
|
||||||
fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
|
private fun getUriToDrawable(@AnyRes drawableId: Int): Uri {
|
||||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||||
+ '/' + context.resources.getResourceTypeName(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())
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(R.drawable.icon_library_folder).toString())
|
||||||
}.build()
|
}.build()
|
||||||
|
|
||||||
if (!itemsInProgress.isEmpty()) {
|
if (itemsInProgress.isNotEmpty()) {
|
||||||
rootList += continueListeningMetadata
|
rootList += continueListeningMetadata
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ package com.audiobookshelf.app.player
|
|||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.*
|
||||||
import android.os.Handler
|
|
||||||
import android.os.Looper
|
|
||||||
import android.os.Message
|
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import android.view.KeyEvent
|
import android.view.KeyEvent
|
||||||
@@ -18,7 +15,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
var tag = "MediaSessionCallback"
|
var tag = "MediaSessionCallback"
|
||||||
|
|
||||||
private var mediaButtonClickCount: Int = 0
|
private var mediaButtonClickCount: Int = 0
|
||||||
var mediaButtonClickTimeout: Long = 1000 //ms
|
private var mediaButtonClickTimeout: Long = 1000 //ms
|
||||||
|
|
||||||
override fun onPrepare() {
|
override fun onPrepare() {
|
||||||
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
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")
|
Log.e(tag, "Failed to play library item")
|
||||||
} else {
|
} else {
|
||||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Handler(Looper.getMainLooper()).post {
|
||||||
playerNotificationService.preparePlayer(it,true, playbackRate)
|
playerNotificationService.preparePlayer(it,true, playbackRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,7 +51,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
Log.e(tag, "Failed to play library item")
|
Log.e(tag, "Failed to play library item")
|
||||||
} else {
|
} else {
|
||||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Handler(Looper.getMainLooper()).post {
|
||||||
playerNotificationService.preparePlayer(it, true, playbackRate)
|
playerNotificationService.preparePlayer(it, true, playbackRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,7 +90,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
|
|
||||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||||
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
|
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
|
||||||
var libraryItemWrapper: LibraryItemWrapper? = null
|
val libraryItemWrapper: LibraryItemWrapper?
|
||||||
var podcastEpisode: PodcastEpisode? = null
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
|
|
||||||
if (mediaId.isNullOrEmpty()) {
|
if (mediaId.isNullOrEmpty()) {
|
||||||
@@ -116,7 +113,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
Log.e(tag, "Failed to play library item")
|
Log.e(tag, "Failed to play library item")
|
||||||
} else {
|
} else {
|
||||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Handler(Looper.getMainLooper()).post {
|
||||||
playerNotificationService.preparePlayer(it, true, playbackRate)
|
playerNotificationService.preparePlayer(it, true, playbackRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,11 +125,17 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
return handleCallMediaButton(mediaButtonEvent)
|
return handleCallMediaButton(mediaButtonEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun handleCallMediaButton(intent: Intent): Boolean {
|
private fun handleCallMediaButton(intent: Intent): Boolean {
|
||||||
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
|
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
|
||||||
|
|
||||||
if(Intent.ACTION_MEDIA_BUTTON == 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}")
|
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
|
||||||
|
|
||||||
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
|
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
|
||||||
@@ -166,7 +169,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
Log.d(tag, "handleCallMediaButton: Media Play")
|
Log.d(tag, "handleCallMediaButton: Media Play")
|
||||||
if (0 == mediaButtonClickCount) {
|
if (0 == mediaButtonClickCount) {
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
|
|
||||||
}
|
}
|
||||||
handleMediaButtonClickCount()
|
handleMediaButtonClickCount()
|
||||||
}
|
}
|
||||||
@@ -181,6 +183,12 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||||
playerNotificationService.jumpBackward()
|
playerNotificationService.jumpBackward()
|
||||||
}
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||||
|
playerNotificationService.jumpForward()
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||||
|
playerNotificationService.jumpBackward()
|
||||||
|
}
|
||||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||||
playerNotificationService.closePlayback()
|
playerNotificationService.closePlayback()
|
||||||
}
|
}
|
||||||
@@ -192,7 +200,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
|||||||
} else {
|
} else {
|
||||||
if (0 == mediaButtonClickCount) {
|
if (0 == mediaButtonClickCount) {
|
||||||
playerNotificationService.play()
|
playerNotificationService.play()
|
||||||
playerNotificationService.sleepTimerManager.checkShouldExtendSleepTimer()
|
|
||||||
}
|
}
|
||||||
handleMediaButtonClickCount()
|
handleMediaButtonClickCount()
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -35,7 +35,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
|||||||
Log.e(tag, "Failed to play library item")
|
Log.e(tag, "Failed to play library item")
|
||||||
} else {
|
} else {
|
||||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Handler(Looper.getMainLooper()).post {
|
||||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -46,7 +46,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
|||||||
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||||
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
|
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
|
||||||
|
|
||||||
var libraryItemWrapper: LibraryItemWrapper? = null
|
val libraryItemWrapper: LibraryItemWrapper?
|
||||||
var podcastEpisode: PodcastEpisode? = null
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
|
|
||||||
if (mediaId.startsWith("ep_") || mediaId.startsWith("local_ep_")) { // Playing podcast episode
|
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")
|
Log.e(tag, "Failed to play library item")
|
||||||
} else {
|
} else {
|
||||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Handler(Looper.getMainLooper()).post {
|
||||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificat
|
|||||||
Log.e(tag, "Failed to play library item")
|
Log.e(tag, "Failed to play library item")
|
||||||
} else {
|
} else {
|
||||||
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
Handler(Looper.getMainLooper()).post() {
|
Handler(Looper.getMainLooper()).post {
|
||||||
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
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_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
|
||||||
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
|
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
|
||||||
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
|
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
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
import com.audiobookshelf.app.data.PlayerState
|
import com.audiobookshelf.app.data.PlayerState
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.google.android.exoplayer2.PlaybackException
|
import com.google.android.exoplayer2.PlaybackException
|
||||||
@@ -13,16 +14,101 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
var lastPauseTime: Long = 0 //ms
|
var lastPauseTime: Long = 0 //ms
|
||||||
|
var lazyIsPlaying: Boolean = false
|
||||||
}
|
}
|
||||||
|
|
||||||
private var onSeekBack: Boolean = false
|
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
val errorMessage = error.message ?: "Unknown Error"
|
val errorMessage = error.message ?: "Unknown Error"
|
||||||
Log.e(tag, "onPlayerError $errorMessage")
|
Log.e(tag, "onPlayerError $errorMessage")
|
||||||
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
|
playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
override fun onEvents(player: Player, events: Player.Events) {
|
||||||
Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}")
|
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)
|
Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration)
|
||||||
|
|
||||||
if (lastPauseTime == 0L) {
|
if (lastPauseTime == 0L) {
|
||||||
lastPauseTime = -1;
|
lastPauseTime = -1
|
||||||
}
|
}
|
||||||
playerNotificationService.sendClientMetadata(PlayerState.READY)
|
playerNotificationService.sendClientMetadata(PlayerState.READY)
|
||||||
}
|
}
|
||||||
@@ -52,6 +138,8 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
|
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) {
|
||||||
Log.d(tag, "STATE_ENDED")
|
Log.d(tag, "STATE_ENDED")
|
||||||
playerNotificationService.sendClientMetadata(PlayerState.ENDED)
|
playerNotificationService.sendClientMetadata(PlayerState.ENDED)
|
||||||
|
|
||||||
|
playerNotificationService.handlePlaybackEnded()
|
||||||
}
|
}
|
||||||
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
|
if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) {
|
||||||
Log.d(tag, "STATE_IDLE")
|
Log.d(tag, "STATE_IDLE")
|
||||||
@@ -65,62 +153,6 @@ class PlayerListener(var playerNotificationService:PlayerNotificationService) :
|
|||||||
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
|
if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) {
|
||||||
Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}")
|
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 {
|
private fun calcPauseSeekBackTime() : Long {
|
||||||
|
|||||||
+132
-42
@@ -3,7 +3,9 @@ package com.audiobookshelf.app.player
|
|||||||
import android.app.*
|
import android.app.*
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.graphics.Color
|
import android.graphics.Color
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
import android.hardware.Sensor
|
import android.hardware.Sensor
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
import android.net.ConnectivityManager
|
import android.net.ConnectivityManager
|
||||||
@@ -11,8 +13,10 @@ import android.net.Network
|
|||||||
import android.net.NetworkCapabilities
|
import android.net.NetworkCapabilities
|
||||||
import android.net.NetworkRequest
|
import android.net.NetworkRequest
|
||||||
import android.os.*
|
import android.os.*
|
||||||
|
import android.provider.MediaStore
|
||||||
import android.support.v4.media.MediaBrowserCompat
|
import android.support.v4.media.MediaBrowserCompat
|
||||||
import android.support.v4.media.MediaDescriptionCompat
|
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.MediaControllerCompat
|
||||||
import android.support.v4.media.session.MediaSessionCompat
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
import android.support.v4.media.session.PlaybackStateCompat
|
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.*
|
||||||
import com.audiobookshelf.app.data.DeviceInfo
|
import com.audiobookshelf.app.data.DeviceInfo
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
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.media.MediaManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
|
||||||
import com.google.android.exoplayer2.*
|
import com.google.android.exoplayer2.*
|
||||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||||
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
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 SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m
|
||||||
const val PLAYER_CAST = "cast-player";
|
const val PLAYER_CAST = "cast-player"
|
||||||
const val PLAYER_EXO = "exo-player";
|
const val PLAYER_EXO = "exo-player"
|
||||||
|
|
||||||
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||||
|
|
||||||
@@ -57,6 +62,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val tag = "PlayerNotificationService"
|
||||||
|
|
||||||
interface ClientEventEmitter {
|
interface ClientEventEmitter {
|
||||||
fun onPlaybackSession(playbackSession:PlaybackSession)
|
fun onPlaybackSession(playbackSession:PlaybackSession)
|
||||||
fun onPlaybackClosed()
|
fun onPlaybackClosed()
|
||||||
@@ -70,9 +77,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
fun onProgressSyncFailing()
|
fun onProgressSyncFailing()
|
||||||
fun onProgressSyncSuccess()
|
fun onProgressSyncSuccess()
|
||||||
fun onNetworkMeteredChanged(isUnmetered:Boolean)
|
fun onNetworkMeteredChanged(isUnmetered:Boolean)
|
||||||
|
fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val tag = "PlayerService"
|
|
||||||
private val binder = LocalBinder()
|
private val binder = LocalBinder()
|
||||||
|
|
||||||
var clientEventEmitter:ClientEventEmitter? = null
|
var clientEventEmitter:ClientEventEmitter? = null
|
||||||
@@ -97,7 +103,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
private var channelId = "audiobookshelf_channel"
|
private var channelId = "audiobookshelf_channel"
|
||||||
private var channelName = "Audiobookshelf Channel"
|
private var channelName = "Audiobookshelf Channel"
|
||||||
|
|
||||||
private var currentPlaybackSession:PlaybackSession? = null
|
var currentPlaybackSession:PlaybackSession? = null
|
||||||
private var initialPlaybackRate:Float? = null
|
private var initialPlaybackRate:Float? = null
|
||||||
|
|
||||||
private var isAndroidAuto = false
|
private var isAndroidAuto = false
|
||||||
@@ -135,6 +141,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return START_STICKY
|
return START_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Deprecated("Deprecated in Java")
|
||||||
override fun onStart(intent: Intent?, startId: Int) {
|
override fun onStart(intent: Intent?, startId: Int) {
|
||||||
Log.d(tag, "onStart $startId")
|
Log.d(tag, "onStart $startId")
|
||||||
}
|
}
|
||||||
@@ -223,7 +230,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
.apply {
|
.apply {
|
||||||
setSessionActivity(sessionActivityPendingIntent)
|
setSessionActivity(sessionActivityPendingIntent)
|
||||||
isActive = true
|
isActive = true
|
||||||
setFlags(MediaSessionCompat.FLAG_HANDLES_MEDIA_BUTTONS or MediaSessionCompat.FLAG_HANDLES_TRANSPORT_CONTROLS)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
|
val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken)
|
||||||
@@ -250,6 +256,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX)
|
||||||
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
playerNotificationManager.setUseFastForwardActionInCompactView(true)
|
||||||
playerNotificationManager.setUseRewindActionInCompactView(true)
|
playerNotificationManager.setUseRewindActionInCompactView(true)
|
||||||
|
playerNotificationManager.setSmallIcon(R.drawable.exo_icon_localaudio)
|
||||||
|
|
||||||
// Unknown action
|
// Unknown action
|
||||||
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE)
|
||||||
@@ -270,6 +277,17 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
val coverUri = currentPlaybackSession!!.getCoverUri()
|
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
|
// Fix for local images crashing on Android 11 for specific devices
|
||||||
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
// https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958
|
||||||
try {
|
try {
|
||||||
@@ -282,11 +300,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
Log.e(tag, "Grant uri permission error $error")
|
Log.e(tag, "Grant uri permission error $error")
|
||||||
}
|
}
|
||||||
|
|
||||||
return MediaDescriptionCompat.Builder()
|
val extra = Bundle()
|
||||||
.setMediaId(currentPlaybackSession!!.id)
|
extra.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, currentPlaybackSession!!.displayAuthor)
|
||||||
|
|
||||||
|
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||||
|
.setExtras(extra)
|
||||||
.setTitle(currentPlaybackSession!!.displayTitle)
|
.setTitle(currentPlaybackSession!!.displayTitle)
|
||||||
.setSubtitle(currentPlaybackSession!!.displayAuthor)
|
.setIconUri(coverUri)
|
||||||
.setIconUri(coverUri).build()
|
|
||||||
|
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
|
isClosed = false
|
||||||
val customActionProviders = mutableListOf(
|
val customActionProviders = mutableListOf(
|
||||||
JumpBackwardCustomActionProvider(),
|
JumpBackwardCustomActionProvider(),
|
||||||
JumpForwardCustomActionProvider(),
|
JumpForwardCustomActionProvider(),
|
||||||
)
|
)
|
||||||
val metadata = playbackSession.getMediaMetadataCompat()
|
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||||
mediaSession.setMetadata(metadata)
|
mediaSession.setMetadata(metadata)
|
||||||
val mediaItems = playbackSession.getMediaItems()
|
val mediaItems = playbackSession.getMediaItems()
|
||||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||||
@@ -358,9 +392,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
customActionProviders.addAll(listOf(
|
customActionProviders.addAll(listOf(
|
||||||
SkipBackwardCustomActionProvider(),
|
SkipBackwardCustomActionProvider(),
|
||||||
SkipForwardCustomActionProvider(),
|
SkipForwardCustomActionProvider(),
|
||||||
));
|
))
|
||||||
}
|
}
|
||||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray());
|
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||||
|
|
||||||
playbackSession.mediaPlayer = getMediaPlayer()
|
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() {
|
fun startNewPlaybackSession() {
|
||||||
currentPlaybackSession?.let { playbackSession ->
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
val forceTranscode = playbackSession.isHLS // If already HLS then force
|
val forceTranscode = playbackSession.isHLS // If already HLS then force
|
||||||
@@ -576,14 +640,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return currentPlaybackSession?.clone()
|
return currentPlaybackSession?.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getCurrentPlaybackSessionId() :String? {
|
|
||||||
return currentPlaybackSession?.id
|
|
||||||
}
|
|
||||||
|
|
||||||
fun getCurrentBookChapter():BookChapter? {
|
fun getCurrentBookChapter():BookChapter? {
|
||||||
return currentPlaybackSession?.getChapterForTime(this.getCurrentTime())
|
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
|
// Called from PlayerListener play event
|
||||||
// check with server if progress has updated since last play and sync progress update
|
// check with server if progress has updated since last play and sync progress update
|
||||||
fun checkCurrentSessionProgress(seekBackTime:Long):Boolean {
|
fun checkCurrentSessionProgress(seekBackTime:Long):Boolean {
|
||||||
@@ -620,7 +692,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
seekPlayer(playbackSession.currentTimeMs)
|
seekPlayer(playbackSession.currentTimeMs)
|
||||||
// Should already be playing
|
// Should already be playing
|
||||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||||
mediaProgressSyncer.start()
|
currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
|
||||||
clientEventEmitter?.onPlayingUpdate(true)
|
clientEventEmitter?.onPlayingUpdate(true)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -628,9 +700,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
if (seekBackTime > 0L) {
|
if (seekBackTime > 0L) {
|
||||||
seekBackward(seekBackTime)
|
seekBackward(seekBackTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Should already be playing
|
// Should already be playing
|
||||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||||
mediaProgressSyncer.start()
|
currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
|
||||||
clientEventEmitter?.onPlayingUpdate(true)
|
clientEventEmitter?.onPlayingUpdate(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -639,7 +712,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
// Streaming from server so check if playback session still exists on server
|
// Streaming from server so check if playback session still exists on server
|
||||||
Log.d(
|
Log.d(
|
||||||
tag,
|
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) {
|
apiHandler.getPlaybackSession(playbackSession.id) {
|
||||||
if (it == null) {
|
if (it == null) {
|
||||||
@@ -660,7 +733,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||||
mediaProgressSyncer.start()
|
mediaProgressSyncer.play(it)
|
||||||
clientEventEmitter?.onPlayingUpdate(true)
|
clientEventEmitter?.onPlayingUpdate(true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -694,14 +767,23 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun seekPlayer(time: Long) {
|
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) {
|
if (currentPlayer.mediaItemCount > 1) {
|
||||||
currentPlaybackSession?.currentTime = time / 1000.0
|
currentPlaybackSession?.currentTime = timeToSeek / 1000.0
|
||||||
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
|
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
|
||||||
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
|
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
|
||||||
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
|
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
|
||||||
} else {
|
} else {
|
||||||
currentPlayer.seekTo(time)
|
currentPlayer.seekTo(timeToSeek)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -750,10 +832,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
currentPlaybackSession = null
|
currentPlaybackSession = null
|
||||||
|
mediaProgressSyncer.reset()
|
||||||
clientEventEmitter?.onPlaybackClosed()
|
clientEventEmitter?.onPlaybackClosed()
|
||||||
PlayerListener.lastPauseTime = 0
|
PlayerListener.lastPauseTime = 0
|
||||||
isClosed = true
|
isClosed = true
|
||||||
stopForeground(true)
|
stopForeground(Service.STOP_FOREGROUND_REMOVE)
|
||||||
stopSelf()
|
stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,8 +860,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
return DeviceInfo(Build.MANUFACTURER, Build.MODEL, Build.BRAND, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME)
|
||||||
}
|
}
|
||||||
|
|
||||||
@get:JsonIgnore
|
private val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
||||||
val deviceSettings get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default()
|
|
||||||
|
|
||||||
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
fun getPlayItemRequestPayload(forceTranscode:Boolean):PlayItemRequestPayload {
|
||||||
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
return PlayItemRequestPayload(getMediaPlayer(), !forceTranscode, forceTranscode, getDeviceInfo())
|
||||||
@@ -799,11 +881,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
//
|
//
|
||||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
// 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 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 = "/"
|
private val AUTO_MEDIA_ROOT = "/"
|
||||||
@@ -856,7 +933,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
if (parentMediaId.startsWith("li_") || parentMediaId.startsWith("local_")) { // Show podcast episodes
|
||||||
Log.d(tag, "Loading podcast episodes")
|
Log.d(tag, "Loading podcast episodes")
|
||||||
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId) {
|
mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) {
|
||||||
result.sendResult(it)
|
result.sendResult(it)
|
||||||
}
|
}
|
||||||
} else if (::browseTree.isInitialized && browseTree[parentMediaId] == null && mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library
|
} 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 ->
|
mediaManager.loadLibraryItemsWithAudio(parentMediaId) { libraryItems ->
|
||||||
val children = libraryItems.map { libraryItem ->
|
val children = libraryItems.map { libraryItem ->
|
||||||
if (libraryItem.mediaType == "podcast") { // Podcasts are browseable
|
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)
|
MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
||||||
} else {
|
} else {
|
||||||
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItem.id }
|
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)
|
MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -882,13 +961,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
|
|
||||||
localBooks.forEach { localLibraryItem ->
|
localBooks.forEach { localLibraryItem ->
|
||||||
val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id)
|
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)
|
localBrowseItems += MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
localPodcasts.forEach { localLibraryItem ->
|
localPodcasts.forEach { localLibraryItem ->
|
||||||
val mediaDescription = localLibraryItem.getMediaDescription(null)
|
val mediaDescription = localLibraryItem.getMediaDescription(null, ctx)
|
||||||
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE)
|
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}")
|
progress = DeviceManager.dbManager.getLocalMediaProgress("${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}")
|
||||||
} else {
|
} else {
|
||||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id && it.episodeId == itemInProgress.episode.id }
|
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 {
|
} else {
|
||||||
if (itemInProgress.isLocal) {
|
if (itemInProgress.isLocal) {
|
||||||
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
|
progress = DeviceManager.dbManager.getLocalMediaProgress(itemInProgress.libraryItemWrapper.id)
|
||||||
} else {
|
} else {
|
||||||
progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == itemInProgress.libraryItemWrapper.id }
|
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)
|
localBrowseItems += MediaBrowserCompat.MediaItem(mediaDescription, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE)
|
||||||
}
|
}
|
||||||
@@ -961,7 +1051,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
// Shake sensor used for sleep timer
|
// Shake sensor used for sleep timer
|
||||||
fun registerSensor() {
|
fun registerSensor() {
|
||||||
if (isShakeSensorRegistered) {
|
if (isShakeSensorRegistered) {
|
||||||
Log.w(tag, "Shake sensor already registered")
|
Log.i(tag, "Shake sensor already registered")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
shakeSensorUnregisterTask?.cancel()
|
shakeSensorUnregisterTask?.cancel()
|
||||||
@@ -981,7 +1071,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
|||||||
// Unregister shake sensor after wake up expiration
|
// Unregister shake sensor after wake up expiration
|
||||||
shakeSensorUnregisterTask?.cancel()
|
shakeSensorUnregisterTask?.cancel()
|
||||||
shakeSensorUnregisterTask = Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) {
|
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")
|
Log.d(tag, "wake time expired: Unregistering shake sensor")
|
||||||
mSensorManager!!.unregisterListener(mShakeDetector)
|
mSensorManager!!.unregisterListener(mShakeDetector)
|
||||||
isShakeSensorRegistered = false
|
isShakeSensorRegistered = false
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.hardware.Sensor
|
|||||||
import android.hardware.SensorEvent
|
import android.hardware.SensorEvent
|
||||||
import android.hardware.SensorEventListener
|
import android.hardware.SensorEventListener
|
||||||
import android.hardware.SensorManager
|
import android.hardware.SensorManager
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import kotlin.math.sqrt
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
class ShakeDetector : SensorEventListener {
|
class ShakeDetector : SensorEventListener {
|
||||||
@@ -34,7 +35,8 @@ class ShakeDetector : SensorEventListener {
|
|||||||
val gZ = z / SensorManager.GRAVITY_EARTH
|
val gZ = z / SensorManager.GRAVITY_EARTH
|
||||||
// gForce will be close to 1 when there is no movement.
|
// gForce will be close to 1 when there is no movement.
|
||||||
val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ)
|
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()
|
val now = System.currentTimeMillis()
|
||||||
// ignore shake events too close to each other (500ms)
|
// ignore shake events too close to each other (500ms)
|
||||||
if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) {
|
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
|
* from the Google Play Store and run it to see how
|
||||||
* many G's it takes to register a shake
|
* 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_SLOP_TIME_MS = 500
|
||||||
private const val SHAKE_COUNT_RESET_TIME_MS = 3000
|
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.MainActivity
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.media.MediaEventManager
|
||||||
import com.audiobookshelf.app.player.CastManager
|
import com.audiobookshelf.app.player.CastManager
|
||||||
|
import com.audiobookshelf.app.player.PlayerListener
|
||||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
@@ -21,7 +23,7 @@ import org.json.JSONObject
|
|||||||
@CapacitorPlugin(name = "AbsAudioPlayer")
|
@CapacitorPlugin(name = "AbsAudioPlayer")
|
||||||
class AbsAudioPlayer : Plugin() {
|
class AbsAudioPlayer : Plugin() {
|
||||||
private val tag = "AbsAudioPlayer"
|
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 mainActivity: MainActivity
|
||||||
private lateinit var apiHandler:ApiHandler
|
private lateinit var apiHandler:ApiHandler
|
||||||
@@ -88,7 +90,13 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
|
override fun onNetworkMeteredChanged(isUnmetered:Boolean) {
|
||||||
emit("onNetworkMeteredChanged", isUnmetered)
|
emit("onNetworkMeteredChanged", isUnmetered)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) {
|
||||||
|
notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter
|
||||||
}
|
}
|
||||||
mainActivity.pluginCallback = foregroundServiceReady
|
mainActivity.pluginCallback = foregroundServiceReady
|
||||||
}
|
}
|
||||||
@@ -160,6 +168,8 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
val episodeId = call.getString("episodeId", "").toString()
|
val episodeId = call.getString("episodeId", "").toString()
|
||||||
val playWhenReady = call.getBoolean("playWhenReady") == true
|
val playWhenReady = call.getBoolean("playWhenReady") == true
|
||||||
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
val playbackRate = call.getFloat("playbackRate",1f) ?: 1f
|
||||||
|
val startTimeOverride = call.getDouble("startTime")
|
||||||
|
Log.d(tag, "prepareLibraryItem lid=$libraryItemId, startTimeOverride=$startTimeOverride")
|
||||||
|
|
||||||
if (libraryItemId.isEmpty()) {
|
if (libraryItemId.isEmpty()) {
|
||||||
Log.e(tag, "Invalid call to play library item no library item id")
|
Log.e(tag, "Invalid call to play library item no library item id")
|
||||||
@@ -181,10 +191,16 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}")
|
||||||
val playbackSession = it.getPlaybackSession(episode)
|
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
|
if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next
|
||||||
playerNotificationService.mediaProgressSyncer.stop {
|
playerNotificationService.mediaProgressSyncer.stop {
|
||||||
Log.d(tag, "Media progress syncer was already syncing - stopped")
|
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
|
Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw
|
||||||
playerNotificationService.preparePlayer(
|
playerNotificationService.preparePlayer(
|
||||||
playbackSession,
|
playbackSession,
|
||||||
@@ -194,6 +210,7 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
playerNotificationService.mediaProgressSyncer.reset()
|
||||||
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
|
playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -201,28 +218,25 @@ class AbsAudioPlayer : Plugin() {
|
|||||||
}
|
}
|
||||||
} else { // Play library item from server
|
} else { // Play library item from server
|
||||||
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
|
val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false)
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
playerNotificationService.mediaProgressSyncer.stop {
|
||||||
if (it == null) {
|
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||||
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
|
if (it == null) {
|
||||||
} else {
|
call.resolve(JSObject("{\"error\":\"Server play request failed\"}"))
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} 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
|
@PluginMethod
|
||||||
fun seek(call: PluginCall) {
|
fun seek(call: PluginCall) {
|
||||||
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
val time:Int = call.getInt("value", 0) ?: 0 // Value in seconds
|
||||||
|
Log.d(tag, "seek action to $time")
|
||||||
Handler(Looper.getMainLooper()).post {
|
Handler(Looper.getMainLooper()).post {
|
||||||
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
|
playerNotificationService.seekPlayer(time * 1000L) // convert to ms
|
||||||
call.resolve()
|
call.resolve()
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package com.audiobookshelf.app.data
|
package com.audiobookshelf.app.plugins
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.MainActivity
|
import com.audiobookshelf.app.MainActivity
|
||||||
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.media.MediaEventManager
|
||||||
import com.audiobookshelf.app.server.ApiHandler
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
@@ -12,12 +14,11 @@ import com.getcapacitor.annotation.CapacitorPlugin
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.GlobalScope
|
import kotlinx.coroutines.GlobalScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.json.JSONObject
|
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsDatabase")
|
@CapacitorPlugin(name = "AbsDatabase")
|
||||||
class AbsDatabase : Plugin() {
|
class AbsDatabase : Plugin() {
|
||||||
val tag = "AbsDatabase"
|
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 mainActivity: MainActivity
|
||||||
lateinit var apiHandler: ApiHandler
|
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
|
@PluginMethod
|
||||||
fun removeLocalMediaProgress(call:PluginCall) {
|
fun removeLocalMediaProgress(call:PluginCall) {
|
||||||
val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
|
val localMediaProgressId = call.getString("localMediaProgressId", "").toString()
|
||||||
@@ -257,6 +276,8 @@ class AbsDatabase : Plugin() {
|
|||||||
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
|
Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId")
|
||||||
call.resolve()
|
call.resolve()
|
||||||
} else {
|
} else {
|
||||||
|
MediaEventManager.syncEvent(mediaProgress, "Received from webhook event")
|
||||||
|
|
||||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(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\"}"))
|
return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var duration = 0.0
|
val duration: Double
|
||||||
var podcastEpisode:PodcastEpisode? = null
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
if (!localEpisodeId.isNullOrEmpty()) {
|
if (!localEpisodeId.isNullOrEmpty()) {
|
||||||
val podcast = localLibraryItem.media as Podcast
|
val podcast = localLibraryItem.media as Podcast
|
||||||
podcastEpisode = podcast.episodes?.find { episode ->
|
podcastEpisode = podcast.episodes?.find { episode ->
|
||||||
@@ -407,4 +428,19 @@ class AbsDatabase : Plugin() {
|
|||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData)))
|
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.app.DownloadManager
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.util.Log
|
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.MainActivity
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
import com.audiobookshelf.app.device.FolderScanner
|
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.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.core.json.JsonReadFeature
|
||||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
import com.getcapacitor.JSObject
|
import com.getcapacitor.JSObject
|
||||||
@@ -23,108 +20,37 @@ import com.getcapacitor.Plugin
|
|||||||
import com.getcapacitor.PluginCall
|
import com.getcapacitor.PluginCall
|
||||||
import com.getcapacitor.PluginMethod
|
import com.getcapacitor.PluginMethod
|
||||||
import com.getcapacitor.annotation.CapacitorPlugin
|
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
|
import java.io.File
|
||||||
|
|
||||||
@CapacitorPlugin(name = "AbsDownloader")
|
@CapacitorPlugin(name = "AbsDownloader")
|
||||||
class AbsDownloader : Plugin() {
|
class AbsDownloader : Plugin() {
|
||||||
private val tag = "AbsDownloader"
|
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 mainActivity: MainActivity
|
||||||
lateinit var downloadManager: DownloadManager
|
lateinit var downloadManager: DownloadManager
|
||||||
lateinit var apiHandler: ApiHandler
|
lateinit var apiHandler: ApiHandler
|
||||||
lateinit var folderScanner: FolderScanner
|
lateinit var folderScanner: FolderScanner
|
||||||
|
lateinit var downloadItemManager: DownloadItemManager
|
||||||
|
|
||||||
data class DownloadItemPart(
|
private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter {
|
||||||
val id: String,
|
override fun onDownloadItem(downloadItem:DownloadItem) {
|
||||||
val filename: String,
|
notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(downloadItem)))
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
override fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart) {
|
||||||
@JsonIgnore
|
notifyListeners("onDownloadItemPartUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItemPart)))
|
||||||
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 onDownloadItemComplete(jsobj:JSObject) {
|
||||||
|
notifyListeners("onItemDownloadComplete", jsobj)
|
||||||
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 load() {
|
override fun load() {
|
||||||
mainActivity = (activity as MainActivity)
|
mainActivity = (activity as MainActivity)
|
||||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||||
folderScanner = FolderScanner(mainActivity)
|
folderScanner = FolderScanner(mainActivity)
|
||||||
apiHandler = ApiHandler(mainActivity)
|
apiHandler = ApiHandler(mainActivity)
|
||||||
|
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
|
||||||
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
|
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")
|
Log.d(tag, "Download library item $libraryItemId to folder $localFolderId / episode: $episodeId")
|
||||||
|
|
||||||
val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$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")
|
Log.d(tag, "Download already started for this media entity $downloadId")
|
||||||
return call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}"))
|
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?) {
|
private fun startLibraryItemDownload(libraryItem: LibraryItem, localFolder: LocalFolder, episode:PodcastEpisode?) {
|
||||||
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
val tempFolderPath = mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
|
||||||
|
|
||||||
|
Log.d(tag, "downloadCacheDirectory=$tempFolderPath")
|
||||||
|
|
||||||
if (libraryItem.mediaType == "book") {
|
if (libraryItem.mediaType == "book") {
|
||||||
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||||
|
val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName())
|
||||||
|
|
||||||
val tracks = libraryItem.media.getAudioTracks()
|
val tracks = libraryItem.media.getAudioTracks()
|
||||||
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
Log.d(tag, "Starting library item download with ${tracks.size} tracks")
|
||||||
val itemFolderPath = localFolder.absolutePath + "/" + bookTitle
|
val itemSubfolder = "$bookAuthor/$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 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
|
// Create download item part for each audio track
|
||||||
tracks.forEach { audioTrack ->
|
tracks.forEach { audioTrack ->
|
||||||
|
val fileSize = audioTrack.metadata?.size ?: 0
|
||||||
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
val serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack.relPath)}"
|
||||||
val destinationFilename = getFilenameFromRelPath(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 finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
val destinationFile = File("$tempFolderPath/$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()) {
|
if (finalDestinationFile.exists()) {
|
||||||
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
Log.d(tag, "Audio file already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||||
finalDestinationFile.delete()
|
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)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
|
|
||||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
|
||||||
val downloadId = downloadManager.enqueue(dlRequest)
|
|
||||||
downloadItemPart.downloadId = downloadId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (downloadItem.downloadItemParts.isNotEmpty()) {
|
if (downloadItem.downloadItemParts.isNotEmpty()) {
|
||||||
// Add cover download item
|
// Add cover download item
|
||||||
if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) {
|
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 serverPath = "/api/items/${libraryItem.id}/cover"
|
||||||
val destinationFilename = "cover.jpg"
|
val destinationFilename = "cover-${libraryItem.id}.jpg"
|
||||||
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
val destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||||
val finalDestinationFile = File("$itemFolderPath/$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()) {
|
if (finalDestinationFile.exists()) {
|
||||||
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
Log.d(tag, "Cover already exists, removing it from ${finalDestinationFile.absolutePath}")
|
||||||
finalDestinationFile.delete()
|
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)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
|
|
||||||
val dlRequest = downloadItemPart.getDownloadRequest()
|
|
||||||
val downloadId = downloadManager.enqueue(dlRequest)
|
|
||||||
downloadItemPart.downloadId = downloadId
|
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadQueue.add(downloadItem)
|
downloadItemManager.addDownloadItem(downloadItem)
|
||||||
startWatchingDownloads(downloadItem)
|
|
||||||
DeviceManager.dbManager.saveDownloadItem(downloadItem)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Podcast episode download
|
// Podcast episode download
|
||||||
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title)
|
||||||
|
|
||||||
val audioTrack = episode?.audioTrack
|
val audioTrack = episode?.audioTrack
|
||||||
|
val fileSize = audioTrack?.metadata?.size ?: 0
|
||||||
|
|
||||||
Log.d(tag, "Starting podcast episode download")
|
Log.d(tag, "Starting podcast episode download")
|
||||||
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
val itemFolderPath = localFolder.absolutePath + "/" + podcastTitle
|
||||||
val downloadItemId = "${libraryItem.id}-${episode?.id}"
|
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 serverPath = "/s/item/${libraryItem.id}/${cleanRelPath(audioTrack?.relPath ?: "")}"
|
||||||
var destinationFilename = getFilenameFromRelPath(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 destinationFile = File("$tempFolderPath/$destinationFilename")
|
||||||
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
var finalDestinationFile = File("$itemFolderPath/$destinationFilename")
|
||||||
@@ -281,14 +217,13 @@ class AbsDownloader : Plugin() {
|
|||||||
finalDestinationFile.delete()
|
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)
|
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) {
|
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"
|
serverPath = "/api/items/${libraryItem.id}/cover"
|
||||||
destinationFilename = "cover.jpg"
|
destinationFilename = "cover.jpg"
|
||||||
|
|
||||||
@@ -298,134 +233,12 @@ class AbsDownloader : Plugin() {
|
|||||||
if (finalDestinationFile.exists()) {
|
if (finalDestinationFile.exists()) {
|
||||||
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
Log.d(tag, "Podcast cover already exists - not downloading cover again")
|
||||||
} else {
|
} else {
|
||||||
downloadItemPart = DownloadItemPart.make(destinationFilename,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null)
|
||||||
downloadItem.downloadItemParts.add(downloadItemPart)
|
downloadItem.downloadItemParts.add(downloadItemPart)
|
||||||
|
|
||||||
dlRequest = downloadItemPart.getDownloadRequest()
|
|
||||||
downloadId = downloadManager.enqueue(dlRequest)
|
|
||||||
downloadItemPart.downloadId = downloadId
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
downloadQueue.add(downloadItem)
|
downloadItemManager.addDownloadItem(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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import kotlinx.coroutines.launch
|
|||||||
class AbsFileSystem : Plugin() {
|
class AbsFileSystem : Plugin() {
|
||||||
private val TAG = "AbsFileSystem"
|
private val TAG = "AbsFileSystem"
|
||||||
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
|
lateinit var mainActivity: MainActivity
|
||||||
|
|
||||||
@@ -85,21 +85,28 @@ class AbsFileSystem : Plugin() {
|
|||||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder)))
|
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()
|
val jsobj = JSObject()
|
||||||
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
if (requestCode == REQUEST_CODE_SELECT_FOLDER) {
|
||||||
|
|
||||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||||
builder.setMessage(
|
builder.setMessage(
|
||||||
"You have no write access to this storage, thus selecting this folder is useless." +
|
"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") { _, _ ->
|
builder.setNegativeButton("Dont Allow") { _, _ ->
|
||||||
run {
|
run {
|
||||||
jsobj.put("error", "User Canceled, Access Denied")
|
jsobj.put("error", "User Canceled, Access Denied")
|
||||||
call.resolve(jsobj)
|
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()
|
builder.show()
|
||||||
} else {
|
} else {
|
||||||
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
Log.d(TAG, "STORAGE ACCESS DENIED $requestCode")
|
||||||
@@ -131,7 +138,7 @@ class AbsFileSystem : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun checkStoragePermission(call: PluginCall) {
|
fun checkStoragePermission(call: PluginCall) {
|
||||||
var res: Boolean
|
val res: Boolean
|
||||||
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
||||||
res = SimpleStorage.hasStoragePermission(context)
|
res = SimpleStorage.hasStoragePermission(context)
|
||||||
Log.d(TAG, "checkStoragePermission: Check Storage Access $res")
|
Log.d(TAG, "checkStoragePermission: Check Storage Access $res")
|
||||||
@@ -140,33 +147,33 @@ class AbsFileSystem : Plugin() {
|
|||||||
res = true
|
res = true
|
||||||
}
|
}
|
||||||
|
|
||||||
var jsobj = JSObject()
|
val jsobj = JSObject()
|
||||||
jsobj.put("value", res)
|
jsobj.put("value", res)
|
||||||
call.resolve(jsobj)
|
call.resolve(jsobj)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun checkFolderPermissions(call: PluginCall) {
|
fun checkFolderPermissions(call: PluginCall) {
|
||||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
val folderUrl = call.data.getString("folderUrl", "").toString()
|
||||||
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
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)
|
jsobj.put("value", hasAccess)
|
||||||
call.resolve(jsobj)
|
call.resolve(jsobj)
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun scanFolder(call: PluginCall) {
|
fun scanFolder(call: PluginCall) {
|
||||||
var folderId = call.data.getString("folderId", "").toString()
|
val folderId = call.data.getString("folderId", "").toString()
|
||||||
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||||
Log.d(TAG, "Scan Folder $folderId | Force Audio Probe $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 {
|
folder?.let {
|
||||||
var folderScanner = FolderScanner(context)
|
val folderScanner = FolderScanner(context)
|
||||||
var folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
|
val folderScanResult = folderScanner.scanForMediaItems(it, forceAudioProbe)
|
||||||
if (folderScanResult == null) {
|
if (folderScanResult == null) {
|
||||||
Log.d(TAG, "NO Scan DATA")
|
Log.d(TAG, "NO Scan DATA")
|
||||||
return call.resolve(JSObject())
|
return call.resolve(JSObject())
|
||||||
@@ -179,28 +186,28 @@ class AbsFileSystem : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun removeFolder(call: PluginCall) {
|
fun removeFolder(call: PluginCall) {
|
||||||
var folderId = call.data.getString("folderId", "").toString()
|
val folderId = call.data.getString("folderId", "").toString()
|
||||||
DeviceManager.dbManager.removeLocalFolder(folderId)
|
DeviceManager.dbManager.removeLocalFolder(folderId)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun removeLocalLibraryItem(call: PluginCall) {
|
fun removeLocalLibraryItem(call: PluginCall) {
|
||||||
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||||
call.resolve()
|
call.resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun scanLocalLibraryItem(call: PluginCall) {
|
fun scanLocalLibraryItem(call: PluginCall) {
|
||||||
var localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString()
|
||||||
var forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
val forceAudioProbe = call.data.getBoolean("forceAudioProbe")
|
||||||
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
|
Log.d(TAG, "Scan Local library item $localLibraryItemId | Force Audio Probe $forceAudioProbe")
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
var localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
val localLibraryItem: LocalLibraryItem? = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||||
localLibraryItem?.let {
|
localLibraryItem?.let {
|
||||||
var folderScanner = FolderScanner(context)
|
val folderScanner = FolderScanner(context)
|
||||||
var scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
|
val scanResult = folderScanner.scanLocalLibraryItem(it, forceAudioProbe)
|
||||||
if (scanResult == null) {
|
if (scanResult == null) {
|
||||||
Log.d(TAG, "NO Scan DATA")
|
Log.d(TAG, "NO Scan DATA")
|
||||||
call.resolve(JSObject())
|
call.resolve(JSObject())
|
||||||
@@ -214,13 +221,13 @@ class AbsFileSystem : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun deleteItem(call: PluginCall) {
|
fun deleteItem(call: PluginCall) {
|
||||||
var localLibraryItemId = call.data.getString("id", "").toString()
|
val localLibraryItemId = call.data.getString("id", "").toString()
|
||||||
var absolutePath = call.data.getString("absolutePath", "").toString()
|
val absolutePath = call.data.getString("absolutePath", "").toString()
|
||||||
var contentUrl = call.data.getString("contentUrl", "").toString()
|
val contentUrl = call.data.getString("contentUrl", "").toString()
|
||||||
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
Log.d(tag, "deleteItem $absolutePath | $contentUrl")
|
||||||
|
|
||||||
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||||
var success = docfile?.delete() == true
|
val success = docfile?.delete() == true
|
||||||
if (success) {
|
if (success) {
|
||||||
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId)
|
||||||
}
|
}
|
||||||
@@ -229,19 +236,19 @@ class AbsFileSystem : Plugin() {
|
|||||||
|
|
||||||
@PluginMethod
|
@PluginMethod
|
||||||
fun deleteTrackFromItem(call: PluginCall) {
|
fun deleteTrackFromItem(call: PluginCall) {
|
||||||
var localLibraryItemId = call.data.getString("id", "").toString()
|
val localLibraryItemId = call.data.getString("id", "").toString()
|
||||||
var trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
|
val trackLocalFileId = call.data.getString("trackLocalFileId", "").toString()
|
||||||
var contentUrl = call.data.getString("trackContentUrl", "").toString()
|
val contentUrl = call.data.getString("trackContentUrl", "").toString()
|
||||||
Log.d(tag, "deleteTrackFromItem $contentUrl")
|
Log.d(tag, "deleteTrackFromItem $contentUrl")
|
||||||
|
|
||||||
var localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId)
|
||||||
if (localLibraryItem == null) {
|
if (localLibraryItem == null) {
|
||||||
Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId")
|
Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId")
|
||||||
return call.resolve(JSObject("{\"success\":false}"))
|
return call.resolve(JSObject("{\"success\":false}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl))
|
||||||
var success = docfile?.delete() == true
|
val success = docfile?.delete() == true
|
||||||
if (success) {
|
if (success) {
|
||||||
localLibraryItem.media.removeAudioTrack(trackLocalFileId)
|
localLibraryItem.media.removeAudioTrack(trackLocalFileId)
|
||||||
localLibraryItem.removeLocalFile(trackLocalFileId)
|
localLibraryItem.removeLocalFile(trackLocalFileId)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import android.net.NetworkCapabilities
|
|||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.audiobookshelf.app.data.*
|
import com.audiobookshelf.app.data.*
|
||||||
import com.audiobookshelf.app.device.DeviceManager
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.media.MediaEventManager
|
||||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
@@ -31,8 +32,8 @@ class ApiHandler(var ctx:Context) {
|
|||||||
|
|
||||||
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
|
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>)
|
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)
|
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) {
|
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||||
val address = config?.address ?: DeviceManager.serverAddress
|
val address = config?.address ?: DeviceManager.serverAddress
|
||||||
@@ -138,12 +139,16 @@ class ApiHandler(var ctx:Context) {
|
|||||||
val mapper = jacksonMapper
|
val mapper = jacksonMapper
|
||||||
getRequest("/api/libraries", null,null) {
|
getRequest("/api/libraries", null,null) {
|
||||||
val libraries = mutableListOf<Library>()
|
val libraries = mutableListOf<Library>()
|
||||||
if (it.has("value")) {
|
|
||||||
val array = it.getJSONArray("value")
|
var array = JSONArray()
|
||||||
for (i in 0 until array.length()) {
|
if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this
|
||||||
val library = mapper.readValue<Library>(array.get(i).toString())
|
array = it.getJSONArray("libraries")
|
||||||
libraries.add(library)
|
} 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)
|
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))
|
val payload = JSObject(jacksonMapper.writeValueAsString(syncData))
|
||||||
|
|
||||||
postRequest("/api/session/$sessionId/sync", payload, null) {
|
postRequest("/api/session/$sessionId/sync", payload, null) {
|
||||||
if (!it.getString("error").isNullOrEmpty()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
cb(false)
|
cb(false, it.getString("error"))
|
||||||
} else {
|
} 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))
|
val payload = JSObject(jacksonMapper.writeValueAsString(playbackSession))
|
||||||
|
|
||||||
postRequest("/api/session/local", payload, null) {
|
postRequest("/api/session/local", payload, null) {
|
||||||
if (!it.getString("error").isNullOrEmpty()) {
|
if (!it.getString("error").isNullOrEmpty()) {
|
||||||
cb(false)
|
cb(false, it.getString("error"))
|
||||||
} else {
|
} else {
|
||||||
cb(true)
|
cb(true, null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,7 +254,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
|
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
|
||||||
if (!isOnline()) {
|
if (!isOnline()) {
|
||||||
Log.d(tag, "Error not online")
|
Log.d(tag, "Error not online")
|
||||||
cb(LocalMediaProgressSyncResultsPayload(0,0,0))
|
cb(LocalMediaProgressSyncResultsPayload(0,0,0, mutableListOf()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -258,7 +263,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
|
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
|
||||||
}
|
}
|
||||||
|
|
||||||
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0)
|
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0, mutableListOf())
|
||||||
|
|
||||||
if (localMediaProgress.isNotEmpty()) {
|
if (localMediaProgress.isNotEmpty()) {
|
||||||
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
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())
|
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
|
||||||
|
|
||||||
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
|
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
|
||||||
|
localSyncResultsPayload.serverProgressUpdates = progressSyncResponsePayload.serverProgressUpdates
|
||||||
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
|
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
|
||||||
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
|
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
|
||||||
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
|
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
|
||||||
// Update all local media progress
|
// Update all local media progress
|
||||||
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
|
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
|
||||||
|
MediaEventManager.syncEvent(localMediaProgress, "Local progress updated. Received from server sync local API request")
|
||||||
|
|
||||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
progressSyncResponsePayload.serverProgressUpdates.forEach { localMediaProgress ->
|
||||||
|
MediaEventManager.syncEvent(localMediaProgress, "Server progress updated. Received from server sync local API request")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
cb(localSyncResultsPayload)
|
cb(localSyncResultsPayload)
|
||||||
@@ -317,6 +329,7 @@ class ApiHandler(var ctx:Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
|
fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) {
|
||||||
|
Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}")
|
||||||
val endpoint = "/api/session/$playbackSessionId"
|
val endpoint = "/api/session/$playbackSessionId"
|
||||||
getRequest(endpoint, null, null) {
|
getRequest(endpoint, null, null) {
|
||||||
val err = it.getString("error")
|
val err = it.getString("error")
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<item name="colorAccent">@color/colorAccent</item>
|
<item name="colorAccent">@color/colorAccent</item>
|
||||||
</style>
|
</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="windowActionBar">false</item>
|
||||||
<item name="windowNoTitle">true</item>
|
<item name="windowNoTitle">true</item>
|
||||||
<item name="android:background">@null</item>
|
<item name="android:background">@null</item>
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
||||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
<style name="AppTheme.NoActionBarLaunch" parent="Theme.SplashScreen">
|
||||||
<!-- <item name="android:background">@drawable/screen</item>-->
|
<!-- <item name="android:background">@drawable/screen</item>-->
|
||||||
</style>
|
</style>
|
||||||
<style name="Widget.Android.AppWidget.Container" parent="android:Widget">
|
<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">
|
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||||
<access origin="*" />
|
<access origin="*" />
|
||||||
|
|
||||||
|
<feature name="CDVOrientation">
|
||||||
|
<param name="android-package" value="cordova.plugins.screenorientation.CDVOrientation"/>
|
||||||
|
</feature>
|
||||||
|
|
||||||
|
|
||||||
</widget>
|
</widget>
|
||||||
@@ -1,15 +1,15 @@
|
|||||||
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.5.30'
|
ext.kotlin_version = '1.7.20'
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.google.gms:google-services:4.3.10'
|
classpath 'com.google.gms:google-services:4.3.13'
|
||||||
classpath 'com.android.tools.build:gradle:7.2.2'
|
classpath 'com.android.tools.build:gradle:7.4.0'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
@@ -29,3 +29,4 @@ allprojects {
|
|||||||
task clean(type: Delete) {
|
task clean(type: Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/
|
|||||||
include ':capacitor-network'
|
include ':capacitor-network'
|
||||||
project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android')
|
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'
|
include ':capacitor-status-bar'
|
||||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
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
|
#Sun Apr 17 13:28:55 CDT 2022
|
||||||
distributionBase=GRADLE_USER_HOME
|
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
|
distributionPath=wrapper/dists
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
+12
-10
@@ -1,16 +1,16 @@
|
|||||||
ext {
|
ext {
|
||||||
minSdkVersion = 24
|
minSdkVersion = 24
|
||||||
compileSdkVersion = 31
|
compileSdkVersion = 33
|
||||||
targetSdkVersion = 30
|
targetSdkVersion = 32
|
||||||
androidxActivityVersion = '1.2.0'
|
androidxActivityVersion = '1.4.0'
|
||||||
androidxAppCompatVersion = '1.4.1'
|
androidxAppCompatVersion = '1.4.2'
|
||||||
androidxCoordinatorLayoutVersion = '1.2.0'
|
androidxCoordinatorLayoutVersion = '1.2.0'
|
||||||
androidxCoreVersion = '1.6.0'
|
androidxCoreVersion = '1.8.0'
|
||||||
androidPlayCore = '1.9.0'
|
androidPlayCore = '1.9.0'
|
||||||
androidxFragmentVersion = '1.3.0'
|
androidxFragmentVersion = '1.4.1'
|
||||||
junitVersion = '4.13.1'
|
junitVersion = '4.13.2'
|
||||||
androidxJunitVersion = '1.1.2'
|
androidxJunitVersion = '1.1.3'
|
||||||
androidxEspressoCoreVersion = '3.3.0'
|
androidxEspressoCoreVersion = '3.4.0'
|
||||||
cordovaAndroidVersion = '10.1.1'
|
cordovaAndroidVersion = '10.1.1'
|
||||||
androidx_car_version = '1.0.0-alpha7'
|
androidx_car_version = '1.0.0-alpha7'
|
||||||
androidx_core_ktx_version = '1.7.0'
|
androidx_core_ktx_version = '1.7.0'
|
||||||
@@ -27,11 +27,13 @@ ext {
|
|||||||
gradle_version = '3.1.4'
|
gradle_version = '3.1.4'
|
||||||
gson_version = '2.8.5'
|
gson_version = '2.8.5'
|
||||||
junit_version = '4.13'
|
junit_version = '4.13'
|
||||||
kotlin_version = '1.5.30'
|
kotlin_version = '1.7.20'
|
||||||
kotlin_coroutines_version = '1.1.0'
|
kotlin_coroutines_version = '1.1.0'
|
||||||
multidex_version = '1.0.3'
|
multidex_version = '1.0.3'
|
||||||
play_services_auth_version = '18.1.0'
|
play_services_auth_version = '18.1.0'
|
||||||
recycler_view_version = '1.1.0'
|
recycler_view_version = '1.1.0'
|
||||||
robolectric_version = '4.2'
|
robolectric_version = '4.2'
|
||||||
test_runner_version = '1.1.0'
|
test_runner_version = '1.1.0'
|
||||||
|
coreSplashScreenVersion = '1.0.0-rc01'
|
||||||
|
androidxWebkitVersion = '1.4.0'
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -13,7 +13,6 @@
|
|||||||
.abs-icons {
|
.abs-icons {
|
||||||
/* use !important to prevent issues with browser extensions that change fonts */
|
/* use !important to prevent issues with browser extensions that change fonts */
|
||||||
font-family: 'absicons' !important;
|
font-family: 'absicons' !important;
|
||||||
speak: never;
|
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-variant: normal;
|
font-variant: normal;
|
||||||
@@ -25,6 +24,18 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-list:before {
|
||||||
|
content: "\e907";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-home:before {
|
||||||
|
content: "\e909";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-authors:before {
|
||||||
|
content: "\e90a";
|
||||||
|
}
|
||||||
|
|
||||||
.icon-books-1:before {
|
.icon-books-1:before {
|
||||||
content: "\e905";
|
content: "\e905";
|
||||||
}
|
}
|
||||||
@@ -53,6 +64,10 @@
|
|||||||
content: "\e901";
|
content: "\e901";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-columns:before {
|
||||||
|
content: "\e90b";
|
||||||
|
}
|
||||||
|
|
||||||
.icon-headphones:before {
|
.icon-headphones:before {
|
||||||
content: "\e910";
|
content: "\e910";
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -2,14 +2,14 @@
|
|||||||
font-family: 'Material Icons';
|
font-family: 'Material Icons';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/MaterialIcons.woff2) format('woff2');
|
src: url(/fonts/MaterialIcons-Regular.ttf) format('truetype');
|
||||||
}
|
}
|
||||||
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Material Icons Outlined';
|
font-family: 'Material Icons Outlined';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url(/fonts/MaterialIconsOutlined.woff2) format('woff2');
|
src: url(/fonts/MaterialIconsOutlined-Regular.otf) format('opentype');
|
||||||
}
|
}
|
||||||
|
|
||||||
.material-icons {
|
.material-icons {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-16 bg-primary relative">
|
<div class="w-full h-16 bg-primary relative z-20">
|
||||||
<div id="appbar" class="absolute top-0 left-0 w-full h-full z-10 flex items-center px-2">
|
<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">
|
<nuxt-link v-show="!showBack" to="/" class="mr-3">
|
||||||
<img src="/Logo.png" class="h-10 w-10" />
|
<img src="/Logo.png" class="h-10 w-10" />
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
@@ -21,12 +21,12 @@
|
|||||||
<widgets-download-progress-indicator />
|
<widgets-download-progress-indicator />
|
||||||
|
|
||||||
<!-- Must be connected to a server to cast, only supports media items on server -->
|
<!-- 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">
|
<div v-show="isCastAvailable && user" class="mx-2 cursor-pointer flex items-center pt-0.5" @click="castClick">
|
||||||
<span class="material-icons text-2xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
<span class="material-icons" :class="isCasting ? 'text-success' : ''">cast</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nuxt-link v-if="user" class="h-7 mx-1.5" to="/search">
|
<nuxt-link v-if="user" class="h-7 mx-1.5" style="padding-top: 3px" to="/search">
|
||||||
<span class="material-icons" style="font-size: 1.75rem">search</span>
|
<span class="material-icons">search</span>
|
||||||
</nuxt-link>
|
</nuxt-link>
|
||||||
|
|
||||||
<div class="h-7 mx-1.5">
|
<div class="h-7 mx-1.5">
|
||||||
|
|||||||
+203
-116
@@ -1,19 +1,21 @@
|
|||||||
<template>
|
<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="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 bg-bg absolute top-0 left-0 pointer-events-auto">
|
<div v-if="showFullscreen" class="w-full h-full z-10 absolute top-0 left-0 pointer-events-auto" :style="{ backgroundColor: coverRgb }">
|
||||||
<div class="top-2 left-4 absolute cursor-pointer">
|
<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%)" />
|
||||||
<span class="material-icons text-5xl" @click="collapseFullscreen">expand_more</span>
|
|
||||||
|
<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>
|
||||||
<div v-show="showCastBtn" class="top-4 right-16 absolute cursor-pointer">
|
<div v-show="showCastBtn" class="top-6 right-16 absolute cursor-pointer">
|
||||||
<span class="material-icons text-3xl" :class="isCasting ? 'text-success' : ''" @click="castClick">cast</span>
|
<span class="material-icons text-3xl" :class="isCasting ? (coverBgIsLight ? 'text-successDark' : 'text-success') : coverBgIsLight ? 'text-black' : ''" @click="castClick">cast</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="top-4 right-4 absolute cursor-pointer">
|
<div class="top-6 right-4 absolute cursor-pointer">
|
||||||
<span class="material-icons text-3xl" @click="showMoreMenuDialog = true">more_vert</span>
|
<span class="material-icons text-3xl" :class="{ 'text-black text-opacity-75': coverBgIsLight }" @click="showMoreMenuDialog = true">more_vert</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="top-2 absolute left-0 right-0 mx-auto text-center uppercase tracking-widest text-opacity-75" style="font-size: 10px" :class="{ 'text-success': isLocalPlayMethod, 'text-accent': !isLocalPlayMethod }">{{ isDirectPlayMethod ? 'Direct' : isLocalPlayMethod ? 'Local' : 'Transcode' }}</p>
|
<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>
|
||||||
|
|
||||||
<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">
|
<div class="flex">
|
||||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ currentTimePretty }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@@ -29,8 +31,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="cover-wrapper absolute z-30 pointer-events-auto" @click="clickContainer">
|
<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">
|
<div class="w-full h-full flex justify-center">
|
||||||
<covers-book-cover v-if="libraryItem || localLibraryItemCoverSrc" :library-item="libraryItem" :download-cover="localLibraryItemCoverSrc" :width="bookCoverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
<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>
|
||||||
|
|
||||||
<div v-if="syncStatus === $constants.SyncStatus.FAILED" class="absolute top-0 left-0 w-full h-full flex items-center justify-center z-30">
|
<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>
|
||||||
|
|
||||||
<div class="title-author-texts absolute z-30 left-0 right-0 overflow-hidden" @click="clickTitleAndAuthor">
|
<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="title-text truncate">{{ title }}</p>
|
||||||
<p class="author-text text-white text-opacity-75 truncate">by {{ authorName }}</p>
|
<p class="author-text text-white text-opacity-75 truncate">{{ authorName }}</p>
|
||||||
</div>
|
</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 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 top-0 left-0 right-0 w-full py-3 mx-auto px-3" style="max-width: 380px">
|
<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">
|
<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 -->
|
<!-- 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>
|
<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>
|
<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>
|
</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 id="playerControls" class="absolute right-0 bottom-0 py-2">
|
||||||
<div class="flex items-center justify-center">
|
<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="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>
|
<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">
|
<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">
|
||||||
<span v-if="!isLoading" class="material-icons">{{ seekLoading ? 'autorenew' : !isPlaying ? 'play_arrow' : 'pause' }}</span>
|
<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" />
|
<widgets-spinner-icon v-else class="h-8 w-8" />
|
||||||
</div>
|
</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>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div id="playerTrack" class="absolute bottom-0 left-0 w-full px-3">
|
<div id="playerTrack" class="absolute 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 class="flex pb-0.5">
|
||||||
<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">
|
|
||||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem" ref="currentTimestamp">0:00</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
|
<p v-show="showFullscreen" class="text-sm truncate text-white text-opacity-75" style="max-width: 65%">{{ currentChapterTitle }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
<p class="font-mono text-white text-opacity-90" style="font-size: 0.8rem">{{ timeRemainingPretty }}</p>
|
||||||
</div>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
<modals-chapters-modal v-model="showChapterModal" :current-chapter="currentChapter" :chapters="chapters" @select="selectChapter" />
|
||||||
<modals-dialog v-model="showMoreMenuDialog" :items="menuItems" @action="clickMenuAction">
|
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
import { AbsAudioPlayer } from '@/plugins/capacitor'
|
||||||
|
import { FastAverageColor } from 'fast-average-color'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@@ -126,11 +115,13 @@ export default {
|
|||||||
default: () => []
|
default: () => []
|
||||||
},
|
},
|
||||||
sleepTimerRunning: Boolean,
|
sleepTimerRunning: Boolean,
|
||||||
sleepTimeRemaining: Number
|
sleepTimeRemaining: Number,
|
||||||
|
isServerItem: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
windowHeight: 0,
|
windowHeight: 0,
|
||||||
|
windowWidth: 0,
|
||||||
playbackSession: null,
|
playbackSession: null,
|
||||||
showChapterModal: false,
|
showChapterModal: false,
|
||||||
showFullscreen: false,
|
showFullscreen: false,
|
||||||
@@ -156,39 +147,62 @@ export default {
|
|||||||
touchStartTime: 0,
|
touchStartTime: 0,
|
||||||
touchEndY: 0,
|
touchEndY: 0,
|
||||||
useChapterTrack: false,
|
useChapterTrack: false,
|
||||||
|
useTotalTrack: true,
|
||||||
lockUi: false,
|
lockUi: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
touchTrackStart: false,
|
touchTrackStart: false,
|
||||||
dragPercent: 0,
|
dragPercent: 0,
|
||||||
syncStatus: 0,
|
syncStatus: 0,
|
||||||
showMoreMenuDialog: false
|
showMoreMenuDialog: false,
|
||||||
|
coverRgb: 'rgb(55, 56, 56)',
|
||||||
|
coverBgIsLight: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
showFullscreen(val) {
|
showFullscreen(val) {
|
||||||
this.updateScreenSize()
|
this.updateScreenSize()
|
||||||
this.$store.commit('setPlayerFullscreen', !!val)
|
this.$store.commit('setPlayerFullscreen', !!val)
|
||||||
|
},
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
this.updateScreenSize()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
menuItems() {
|
menuItems() {
|
||||||
var items = [
|
const items = []
|
||||||
{
|
// TODO: Implement on iOS
|
||||||
text: 'Chapter Track',
|
if (this.$platform !== 'ios' && !this.isPodcast && this.mediaId) {
|
||||||
value: 'chapter_track',
|
items.push({
|
||||||
icon: this.useChapterTrack ? 'check_box' : 'check_box_outline_blank'
|
text: 'History',
|
||||||
},
|
value: 'history',
|
||||||
{
|
icon: 'history'
|
||||||
text: this.lockUi ? 'Unlock Player' : 'Lock Player',
|
})
|
||||||
value: 'lock',
|
}
|
||||||
icon: this.lockUi ? 'lock' : 'lock_open'
|
|
||||||
},
|
items.push(
|
||||||
{
|
...[
|
||||||
text: 'Close Player',
|
{
|
||||||
value: 'close',
|
text: 'Total Track',
|
||||||
icon: 'close'
|
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
|
return items
|
||||||
},
|
},
|
||||||
@@ -209,14 +223,29 @@ export default {
|
|||||||
},
|
},
|
||||||
bookCoverWidth() {
|
bookCoverWidth() {
|
||||||
if (this.showFullscreen) return this.fullscreenBookCoverWidth
|
if (this.showFullscreen) return this.fullscreenBookCoverWidth
|
||||||
return 60
|
return 46 / this.bookCoverAspectRatio
|
||||||
},
|
},
|
||||||
fullscreenBookCoverWidth() {
|
fullscreenBookCoverWidth() {
|
||||||
var heightScale = (this.windowHeight - 200) / 651
|
if (this.windowWidth < this.windowHeight) {
|
||||||
if (this.bookCoverAspectRatio === 1) {
|
// Portrait
|
||||||
return 260 * heightScale
|
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() {
|
showCastBtn() {
|
||||||
return this.$store.state.isCastAvailable
|
return this.$store.state.isCastAvailable
|
||||||
@@ -324,9 +353,36 @@ export default {
|
|||||||
} else {
|
} else {
|
||||||
return secondsRemaining + 's'
|
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: {
|
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() {
|
clickTitleAndAuthor() {
|
||||||
if (!this.showFullscreen) return
|
if (!this.showFullscreen) return
|
||||||
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
|
const llid = this.libraryItem ? this.libraryItem.id : this.localLibraryItem ? this.localLibraryItem.id : null
|
||||||
@@ -335,15 +391,18 @@ export default {
|
|||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
touchstartTrack(e) {
|
async touchstartTrack(e) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
if (!e || !e.touches || !this.$refs.track || !this.showFullscreen || this.lockUi) return
|
||||||
this.touchTrackStart = true
|
this.touchTrackStart = true
|
||||||
},
|
},
|
||||||
selectChapter(chapter) {
|
async selectChapter(chapter) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.seek(chapter.start)
|
this.seek(chapter.start)
|
||||||
this.showChapterModal = false
|
this.showChapterModal = false
|
||||||
},
|
},
|
||||||
castClick() {
|
async castClick() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.isLocalPlayMethod) {
|
if (this.isLocalPlayMethod) {
|
||||||
this.$eventBus.$emit('cast-local-item')
|
this.$eventBus.$emit('cast-local-item')
|
||||||
return
|
return
|
||||||
@@ -362,12 +421,14 @@ export default {
|
|||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
this.forceCloseDropdownMenu()
|
this.forceCloseDropdownMenu()
|
||||||
},
|
},
|
||||||
jumpNextChapter() {
|
async jumpNextChapter() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.isLoading) return
|
||||||
if (!this.nextChapter) return
|
if (!this.nextChapter) return
|
||||||
this.seek(this.nextChapter.start)
|
this.seek(this.nextChapter.start)
|
||||||
},
|
},
|
||||||
jumpChapterStart() {
|
async jumpChapterStart() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.isLoading) return
|
||||||
if (!this.currentChapter) {
|
if (!this.currentChapter) {
|
||||||
return this.restart()
|
return this.restart()
|
||||||
@@ -375,9 +436,9 @@ export default {
|
|||||||
|
|
||||||
// If 4 seconds or less into current chapter, then go to previous
|
// If 4 seconds or less into current chapter, then go to previous
|
||||||
if (this.currentTime - this.currentChapter.start <= 4) {
|
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) {
|
if (currChapterIndex > 0) {
|
||||||
var prevChapter = this.chapters[currChapterIndex - 1]
|
const prevChapter = this.chapters[currChapterIndex - 1]
|
||||||
this.seek(prevChapter.start)
|
this.seek(prevChapter.start)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -387,7 +448,8 @@ export default {
|
|||||||
showSleepTimerModal() {
|
showSleepTimerModal() {
|
||||||
this.$emit('showSleepTimer')
|
this.$emit('showSleepTimer')
|
||||||
},
|
},
|
||||||
setPlaybackSpeed(speed) {
|
async setPlaybackSpeed(speed) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
|
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
|
||||||
this.currentPlaybackRate = speed
|
this.currentPlaybackRate = speed
|
||||||
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
|
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
|
||||||
@@ -395,11 +457,13 @@ export default {
|
|||||||
restart() {
|
restart() {
|
||||||
this.seek(0)
|
this.seek(0)
|
||||||
},
|
},
|
||||||
jumpBackwards() {
|
async jumpBackwards() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.isLoading) return
|
||||||
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
AbsAudioPlayer.seekBackward({ value: this.jumpBackwardsTime })
|
||||||
},
|
},
|
||||||
jumpForward() {
|
async jumpForward() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.isLoading) return
|
||||||
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
AbsAudioPlayer.seekForward({ value: this.jumpForwardTime })
|
||||||
},
|
},
|
||||||
@@ -408,19 +472,19 @@ export default {
|
|||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
},
|
},
|
||||||
setChunksReady(chunks, numSegments) {
|
setChunksReady(chunks, numSegments) {
|
||||||
var largestSeg = 0
|
let largestSeg = 0
|
||||||
for (let i = 0; i < chunks.length; i++) {
|
for (let i = 0; i < chunks.length; i++) {
|
||||||
var chunk = chunks[i]
|
const chunk = chunks[i]
|
||||||
if (typeof chunk === 'string') {
|
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.length < 2) continue
|
||||||
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
if (chunkRange[1] > largestSeg) largestSeg = chunkRange[1]
|
||||||
} else if (chunk > largestSeg) {
|
} else if (chunk > largestSeg) {
|
||||||
largestSeg = chunk
|
largestSeg = chunk
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var percentageReady = largestSeg / numSegments
|
const percentageReady = largestSeg / numSegments
|
||||||
var widthReady = Math.round(this.trackWidth * percentageReady)
|
const widthReady = Math.round(this.trackWidth * percentageReady)
|
||||||
if (this.readyTrackWidth === widthReady) {
|
if (this.readyTrackWidth === widthReady) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -472,17 +536,17 @@ export default {
|
|||||||
},
|
},
|
||||||
updateTrack() {
|
updateTrack() {
|
||||||
// Update progress track UI
|
// Update progress track UI
|
||||||
var percentDone = this.currentTime / this.totalDuration
|
let percentDone = this.currentTime / this.totalDuration
|
||||||
var totalPercentDone = percentDone
|
const totalPercentDone = percentDone
|
||||||
var bufferedPercent = this.bufferedTime / this.totalDuration
|
let bufferedPercent = this.bufferedTime / this.totalDuration
|
||||||
var totalBufferedPercent = bufferedPercent
|
const totalBufferedPercent = bufferedPercent
|
||||||
|
|
||||||
if (this.useChapterTrack && this.currentChapter) {
|
if (this.useChapterTrack && this.currentChapter) {
|
||||||
var currChapTime = this.currentTime - this.currentChapter.start
|
const currChapTime = this.currentTime - this.currentChapter.start
|
||||||
percentDone = currChapTime / this.currentChapterDuration
|
percentDone = currChapTime / this.currentChapterDuration
|
||||||
bufferedPercent = Math.max(0, Math.min(1, (this.bufferedTime - this.currentChapter.start) / 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.playedTrack.style.width = ptWidth + 'px'
|
||||||
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
this.$refs.bufferedTrack.style.width = Math.round(bufferedPercent * this.trackWidth) + 'px'
|
||||||
|
|
||||||
@@ -539,6 +603,7 @@ export default {
|
|||||||
this.seek(time)
|
this.seek(time)
|
||||||
},
|
},
|
||||||
async playPauseClick() {
|
async playPauseClick() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.isLoading) return
|
if (this.isLoading) return
|
||||||
|
|
||||||
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
this.isPlaying = !!((await AbsAudioPlayer.playPause()) || {}).playing
|
||||||
@@ -641,19 +706,32 @@ export default {
|
|||||||
ts.innerText = currTimeStr
|
ts.innerText = currTimeStr
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
clickMenuAction(action) {
|
async clickMenuAction(action) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.showMoreMenuDialog = false
|
this.showMoreMenuDialog = false
|
||||||
this.$nextTick(() => {
|
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.lockUi = !this.lockUi
|
||||||
this.$localStore.setPlayerLock(this.lockUi)
|
this.$localStore.setPlayerLock(this.lockUi)
|
||||||
} else if (action === 'chapter_track') {
|
} else if (action === 'chapter_track') {
|
||||||
this.useChapterTrack = !this.useChapterTrack
|
this.useChapterTrack = !this.useChapterTrack
|
||||||
|
this.useTotalTrack = !this.useChapterTrack || this.useTotalTrack
|
||||||
|
|
||||||
this.updateTimestamp()
|
this.updateTimestamp()
|
||||||
this.updateTrack()
|
this.updateTrack()
|
||||||
this.updateReadyTrack()
|
this.updateReadyTrack()
|
||||||
this.$localStore.setUseChapterTrack(this.useChapterTrack)
|
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') {
|
} else if (action === 'close') {
|
||||||
this.closePlayback()
|
this.closePlayback()
|
||||||
}
|
}
|
||||||
@@ -750,16 +828,27 @@ export default {
|
|||||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||||
},
|
},
|
||||||
screenOrientationChange() {
|
screenOrientationChange() {
|
||||||
setTimeout(this.updateScreenSize, 50)
|
setTimeout(() => {
|
||||||
|
this.updateScreenSize()
|
||||||
|
if (this.$refs.track) {
|
||||||
|
this.trackWidth = this.$refs.track.clientWidth
|
||||||
|
this.updateTrack()
|
||||||
|
this.updateReadyTrack()
|
||||||
|
}
|
||||||
|
}, 50)
|
||||||
},
|
},
|
||||||
updateScreenSize() {
|
updateScreenSize() {
|
||||||
this.windowHeight = window.innerHeight
|
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-width', this.fullscreenBookCoverWidth + 'px')
|
||||||
document.documentElement.style.setProperty('--cover-image-height', coverHeight + '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() {
|
minimizePlayerEvt() {
|
||||||
console.log('Minimize Player Evt')
|
|
||||||
this.showFullscreen = false
|
this.showFullscreen = false
|
||||||
},
|
},
|
||||||
showProgressSyncIsFailing() {
|
showProgressSyncIsFailing() {
|
||||||
@@ -821,12 +910,11 @@ export default {
|
|||||||
:root {
|
:root {
|
||||||
--cover-image-width: 0px;
|
--cover-image-width: 0px;
|
||||||
--cover-image-height: 0px;
|
--cover-image-height: 0px;
|
||||||
--cover-image-width-collapsed: 60px;
|
--cover-image-width-collapsed: 46px;
|
||||||
--cover-image-height-collapsed: 60px;
|
--cover-image-height-collapsed: 46px;
|
||||||
}
|
--title-author-left-offset-collapsed: 70px;
|
||||||
.bookCoverWrapper {
|
|
||||||
box-shadow: 3px -2px 5px #00000066;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.playerContainer {
|
.playerContainer {
|
||||||
height: 100px;
|
height: 100px;
|
||||||
}
|
}
|
||||||
@@ -836,23 +924,22 @@ export default {
|
|||||||
#playerContent {
|
#playerContent {
|
||||||
box-shadow: 0px -8px 8px #11111155;
|
box-shadow: 0px -8px 8px #11111155;
|
||||||
}
|
}
|
||||||
|
.fullscreen #playerContent {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
#playerTrack {
|
#playerTrack {
|
||||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
transition-property: margin;
|
transition-property: margin;
|
||||||
margin-bottom: 4px;
|
bottom: 20px;
|
||||||
}
|
}
|
||||||
.fullscreen #playerTrack {
|
.fullscreen #playerTrack {
|
||||||
margin-bottom: 18px;
|
top: 20px;
|
||||||
}
|
bottom: unset;
|
||||||
|
|
||||||
.ios-player #timestamp-row {
|
|
||||||
padding-left: 16px;
|
|
||||||
padding-right: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover-wrapper {
|
.cover-wrapper {
|
||||||
bottom: 44px;
|
bottom: 48px;
|
||||||
left: 12px;
|
left: 12px;
|
||||||
height: var(--cover-image-height-collapsed);
|
height: var(--cover-image-height-collapsed);
|
||||||
width: var(--cover-image-width-collapsed);
|
width: var(--cover-image-width-collapsed);
|
||||||
@@ -873,8 +960,8 @@ export default {
|
|||||||
transform-origin: left bottom;
|
transform-origin: left bottom;
|
||||||
|
|
||||||
width: 40%;
|
width: 40%;
|
||||||
bottom: 50px;
|
bottom: 56px;
|
||||||
left: 80px;
|
left: var(--title-author-left-offset-collapsed);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
.title-author-texts .title-text {
|
.title-author-texts .title-text {
|
||||||
@@ -899,10 +986,10 @@ export default {
|
|||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
.fullscreen .title-author-texts .title-text {
|
.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 {
|
.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 {
|
#playerControls {
|
||||||
@@ -912,7 +999,7 @@ export default {
|
|||||||
width: 140px;
|
width: 140px;
|
||||||
padding-left: 12px;
|
padding-left: 12px;
|
||||||
padding-right: 12px;
|
padding-right: 12px;
|
||||||
bottom: 45px;
|
bottom: 50px;
|
||||||
}
|
}
|
||||||
#playerControls .jump-icon {
|
#playerControls .jump-icon {
|
||||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||||
@@ -949,7 +1036,7 @@ export default {
|
|||||||
|
|
||||||
.fullscreen #playerControls {
|
.fullscreen #playerControls {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
bottom: 100px;
|
bottom: 94px;
|
||||||
}
|
}
|
||||||
.fullscreen #playerControls .jump-icon {
|
.fullscreen #playerControls .jump-icon {
|
||||||
margin: 0px 18px;
|
margin: 0px 18px;
|
||||||
@@ -970,4 +1057,4 @@ export default {
|
|||||||
.fullscreen #playerControls .play-btn .material-icons {
|
.fullscreen #playerControls .play-btn .material-icons {
|
||||||
font-size: 2.1rem;
|
font-size: 2.1rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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-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" />
|
<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
|
serverEpisodeId: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
|
||||||
socketConnected(newVal) {
|
|
||||||
if (newVal) {
|
|
||||||
console.log('Socket Connected set listeners')
|
|
||||||
this.setListeners()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
computed: {
|
computed: {
|
||||||
bookmarks() {
|
bookmarks() {
|
||||||
if (!this.serverLibraryItemId) return []
|
if (!this.serverLibraryItemId) return []
|
||||||
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
|
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
|
||||||
},
|
|
||||||
socketConnected() {
|
|
||||||
return this.$store.state.socketConnected
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -62,7 +51,7 @@ export default {
|
|||||||
selectBookmark(bookmark) {
|
selectBookmark(bookmark) {
|
||||||
this.showBookmarksModal = false
|
this.showBookmarksModal = false
|
||||||
if (!bookmark || isNaN(bookmark.time)) return
|
if (!bookmark || isNaN(bookmark.time)) return
|
||||||
var bookmarkTime = Number(bookmark.time)
|
const bookmarkTime = Number(bookmark.time)
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
this.$refs.audioPlayer.seek(bookmarkTime)
|
this.$refs.audioPlayer.seek(bookmarkTime)
|
||||||
}
|
}
|
||||||
@@ -71,8 +60,6 @@ export default {
|
|||||||
this.isSleepTimerRunning = false
|
this.isSleepTimerRunning = false
|
||||||
if (currentPosition) {
|
if (currentPosition) {
|
||||||
console.log('Sleep Timer Ended Current Position: ' + 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 }) {
|
onSleepTimerSet({ value: sleepTimeRemaining }) {
|
||||||
@@ -160,17 +147,6 @@ export default {
|
|||||||
this.notifyOnReady()
|
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() {
|
closeStreamOnly() {
|
||||||
// If user logs out or disconnects from server and not playing local
|
// If user logs out or disconnects from server and not playing local
|
||||||
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isLocalPlayMethod) {
|
if (this.$refs.audioPlayer && !this.$refs.audioPlayer.isLocalPlayMethod) {
|
||||||
@@ -206,13 +182,14 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async playLibraryItem(payload) {
|
async playLibraryItem(payload) {
|
||||||
var libraryItemId = payload.libraryItemId
|
const libraryItemId = payload.libraryItemId
|
||||||
var episodeId = payload.episodeId
|
const episodeId = payload.episodeId
|
||||||
|
const startTime = payload.startTime
|
||||||
|
|
||||||
// When playing local library item and can also play this item from the server
|
// 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
|
// then store the server library item id so it can be used if a cast is made
|
||||||
var serverLibraryItemId = payload.serverLibraryItemId || null
|
const serverLibraryItemId = payload.serverLibraryItemId || null
|
||||||
var serverEpisodeId = payload.serverEpisodeId || null
|
const serverEpisodeId = payload.serverEpisodeId || null
|
||||||
|
|
||||||
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
if (libraryItemId.startsWith('local') && this.$store.state.isCasting) {
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
@@ -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.serverLibraryItemId = null
|
||||||
this.serverEpisodeId = null
|
this.serverEpisodeId = null
|
||||||
|
|
||||||
var playbackRate = 1
|
let playbackRate = 1
|
||||||
if (this.$refs.audioPlayer) {
|
if (this.$refs.audioPlayer) {
|
||||||
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
|
playbackRate = this.$refs.audioPlayer.currentPlaybackRate || 1
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Called playLibraryItem', libraryItemId)
|
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) => {
|
.then((data) => {
|
||||||
if (data.error) {
|
if (data.error) {
|
||||||
const errorMsg = data.error || 'Failed to play'
|
const errorMsg = data.error || 'Failed to play'
|
||||||
@@ -278,7 +267,7 @@ export default {
|
|||||||
notifyOnReady() {
|
notifyOnReady() {
|
||||||
// If settings aren't loaded yet, native player will receive incorrect settings
|
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||||
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
|
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
|
||||||
if ( this.settingsLoaded && this.isReady ) {
|
if (this.settingsLoaded && this.isReady) {
|
||||||
AbsAudioPlayer.onReady()
|
AbsAudioPlayer.onReady()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -292,13 +281,12 @@ export default {
|
|||||||
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
this.playbackSpeed = this.$store.getters['user/getUserSetting']('playbackRate')
|
||||||
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
console.log(`[AudioPlayerContainer] Init Playback Speed: ${this.playbackSpeed}`)
|
||||||
|
|
||||||
this.setListeners()
|
|
||||||
this.$eventBus.$on('abs-ui-ready', this.onReady)
|
this.$eventBus.$on('abs-ui-ready', this.onReady)
|
||||||
this.$eventBus.$on('play-item', this.playLibraryItem)
|
this.$eventBus.$on('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$on('pause-item', this.pauseItem)
|
this.$eventBus.$on('pause-item', this.pauseItem)
|
||||||
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
this.$eventBus.$on('close-stream', this.closeStreamOnly)
|
||||||
this.$eventBus.$on('cast-local-item', this.castLocalItem)
|
this.$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() {
|
beforeDestroy() {
|
||||||
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
if (this.onLocalMediaProgressUpdateListener) this.onLocalMediaProgressUpdateListener.remove()
|
||||||
@@ -306,19 +294,12 @@ export default {
|
|||||||
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
|
if (this.onSleepTimerSetListener) this.onSleepTimerSetListener.remove()
|
||||||
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
|
if (this.onMediaPlayerChangedListener) this.onMediaPlayerChangedListener.remove()
|
||||||
|
|
||||||
// if (this.$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('abs-ui-ready', this.onReady)
|
||||||
this.$eventBus.$off('play-item', this.playLibraryItem)
|
this.$eventBus.$off('play-item', this.playLibraryItem)
|
||||||
this.$eventBus.$off('pause-item', this.pauseItem)
|
this.$eventBus.$off('pause-item', this.pauseItem)
|
||||||
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
this.$eventBus.$off('close-stream', this.closeStreamOnly)
|
||||||
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
this.$eventBus.$off('cast-local-item', this.castLocalItem)
|
||||||
this.$store.commit('user/removeSettingsListener', 'streamContainer')
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -115,10 +115,10 @@ export default {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
icon: 'download',
|
icon: 'download',
|
||||||
iconOutlined: false,
|
iconOutlined: false,
|
||||||
text: 'Downloads',
|
text: 'Downloads',
|
||||||
to: '/downloads'
|
to: '/downloads'
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
items.push({
|
items.push({
|
||||||
@@ -137,6 +137,7 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
async logout() {
|
async logout() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.user) {
|
if (this.user) {
|
||||||
await this.$axios.$post('/logout').catch((error) => {
|
await this.$axios.$post('/logout').catch((error) => {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
|
|||||||
@@ -95,16 +95,16 @@ export default {
|
|||||||
var coverSize = 100
|
var coverSize = 100
|
||||||
if (window.innerWidth <= 375) coverSize = 90
|
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
|
return coverSize
|
||||||
},
|
},
|
||||||
bookHeight() {
|
bookHeight() {
|
||||||
if (this.isCoverSquareAspectRatio) return this.bookWidth
|
if (this.isCoverSquareAspectRatio || this.entityName === 'playlists') return this.bookWidth
|
||||||
return this.bookWidth * 1.6
|
return this.bookWidth * 1.6
|
||||||
},
|
},
|
||||||
entityWidth() {
|
entityWidth() {
|
||||||
if (this.showBookshelfListView) return this.bookshelfWidth - 16
|
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
|
return this.bookWidth * 2
|
||||||
},
|
},
|
||||||
entityHeight() {
|
entityHeight() {
|
||||||
@@ -134,7 +134,7 @@ export default {
|
|||||||
return this.$store.getters['getAltViewEnabled']
|
return this.$store.getters['getAltViewEnabled']
|
||||||
},
|
},
|
||||||
sizeMultiplier() {
|
sizeMultiplier() {
|
||||||
var baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
const baseSize = this.isCoverSquareAspectRatio ? 192 : 120
|
||||||
return this.entityWidth / baseSize
|
return this.entityWidth / baseSize
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -145,7 +145,7 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
async fetchEntities(page) {
|
async fetchEntities(page) {
|
||||||
var startIndex = page * this.booksPerFetch
|
const startIndex = page * this.booksPerFetch
|
||||||
|
|
||||||
this.isFetchingEntities = true
|
this.isFetchingEntities = true
|
||||||
|
|
||||||
@@ -153,11 +153,11 @@ export default {
|
|||||||
this.currentSFQueryString = this.buildSearchParams()
|
this.currentSFQueryString = this.buildSearchParams()
|
||||||
}
|
}
|
||||||
|
|
||||||
var entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
const entityPath = this.entityName === 'books' || this.entityName === 'series-books' ? `items` : this.entityName
|
||||||
var sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
const sfQueryString = this.currentSFQueryString ? this.currentSFQueryString + '&' : ''
|
||||||
var fullQueryString = `?${sfQueryString}limit=${this.booksPerFetch}&page=${page}&minified=1`
|
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)
|
console.error('failed to fetch books', error)
|
||||||
return null
|
return null
|
||||||
})
|
})
|
||||||
@@ -179,13 +179,13 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < payload.results.length; i++) {
|
for (let i = 0; i < payload.results.length; i++) {
|
||||||
var index = i + startIndex
|
const index = i + startIndex
|
||||||
this.entities[index] = payload.results[i]
|
this.entities[index] = payload.results[i]
|
||||||
if (this.entityComponentRefs[index]) {
|
if (this.entityComponentRefs[index]) {
|
||||||
this.entityComponentRefs[index].setEntity(this.entities[index])
|
this.entityComponentRefs[index].setEntity(this.entities[index])
|
||||||
|
|
||||||
if (this.isBookEntity) {
|
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) {
|
if (localLibraryItem) {
|
||||||
this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem)
|
this.entityComponentRefs[index].setLocalLibraryItem(localLibraryItem)
|
||||||
}
|
}
|
||||||
@@ -309,7 +309,7 @@ export default {
|
|||||||
var { clientHeight, clientWidth } = bookshelf
|
var { clientHeight, clientWidth } = bookshelf
|
||||||
this.bookshelfHeight = clientHeight
|
this.bookshelfHeight = clientHeight
|
||||||
this.bookshelfWidth = clientWidth
|
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.shelvesPerPage = Math.ceil(this.bookshelfHeight / this.shelfHeight) + 2
|
||||||
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
|
this.bookshelfMarginLeft = (this.bookshelfWidth - this.entitiesPerShelf * this.totalEntityCardWidth) / 2
|
||||||
|
|
||||||
@@ -356,8 +356,6 @@ export default {
|
|||||||
let searchParams = new URLSearchParams()
|
let searchParams = new URLSearchParams()
|
||||||
if (this.page === 'series-books') {
|
if (this.page === 'series-books') {
|
||||||
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
searchParams.set('filter', `series.${this.$encode(this.seriesId)}`)
|
||||||
searchParams.set('sort', 'book.volumeNumber')
|
|
||||||
searchParams.set('desc', 0)
|
|
||||||
} else {
|
} else {
|
||||||
if (this.filterBy && this.filterBy !== 'all') {
|
if (this.filterBy && this.filterBy !== 'all') {
|
||||||
searchParams.set('filter', this.filterBy)
|
searchParams.set('filter', this.filterBy)
|
||||||
@@ -373,15 +371,16 @@ export default {
|
|||||||
return searchParams.toString()
|
return searchParams.toString()
|
||||||
},
|
},
|
||||||
checkUpdateSearchParams() {
|
checkUpdateSearchParams() {
|
||||||
var newSearchParams = this.buildSearchParams()
|
const newSearchParams = this.buildSearchParams()
|
||||||
var currentQueryString = window.location.search
|
let currentQueryString = window.location.search
|
||||||
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
|
if (currentQueryString && currentQueryString.startsWith('?')) currentQueryString = currentQueryString.slice(1)
|
||||||
|
|
||||||
if (newSearchParams === '') {
|
if (newSearchParams === '' && !currentQueryString) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
if (newSearchParams !== this.currentSFQueryString || newSearchParams !== currentQueryString) {
|
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)
|
window.history.replaceState({ path: newurl }, '', newurl)
|
||||||
|
|
||||||
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
|
this.routeFullPath = window.location.pathname + (window.location.search || '') // Update for saving scroll position
|
||||||
@@ -391,12 +390,17 @@ export default {
|
|||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
settingsUpdated(settings) {
|
settingsUpdated(settings) {
|
||||||
var wasUpdated = this.checkUpdateSearchParams()
|
const wasUpdated = this.checkUpdateSearchParams()
|
||||||
if (wasUpdated) {
|
if (wasUpdated) {
|
||||||
this.resetEntities()
|
this.resetEntities()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
libraryChanged() {
|
libraryChanged() {
|
||||||
|
if (this.currentLibraryMediaType !== 'book' && (this.page === 'series' || this.page === 'collections' || this.page === 'series-books')) {
|
||||||
|
this.$router.replace('/bookshelf')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (this.hasFilter) {
|
if (this.hasFilter) {
|
||||||
this.clearFilter()
|
this.clearFilter()
|
||||||
} else {
|
} else {
|
||||||
@@ -449,13 +453,13 @@ export default {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
initListeners() {
|
initListeners() {
|
||||||
var bookshelf = document.getElementById('bookshelf-wrapper')
|
const bookshelf = document.getElementById('bookshelf-wrapper')
|
||||||
if (bookshelf) {
|
if (bookshelf) {
|
||||||
bookshelf.addEventListener('scroll', this.scroll)
|
bookshelf.addEventListener('scroll', this.scroll)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$eventBus.$on('library-changed', this.libraryChanged)
|
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_updated', this.libraryItemUpdated)
|
||||||
this.$socket.$on('item_added', this.libraryItemAdded)
|
this.$socket.$on('item_added', this.libraryItemAdded)
|
||||||
@@ -464,13 +468,13 @@ export default {
|
|||||||
this.$socket.$on('items_added', this.libraryItemsAdded)
|
this.$socket.$on('items_added', this.libraryItemsAdded)
|
||||||
},
|
},
|
||||||
removeListeners() {
|
removeListeners() {
|
||||||
var bookshelf = document.getElementById('bookshelf-wrapper')
|
const bookshelf = document.getElementById('bookshelf-wrapper')
|
||||||
if (bookshelf) {
|
if (bookshelf) {
|
||||||
bookshelf.removeEventListener('scroll', this.scroll)
|
bookshelf.removeEventListener('scroll', this.scroll)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.$eventBus.$off('library-changed', this.libraryChanged)
|
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_updated', this.libraryItemUpdated)
|
||||||
this.$socket.$off('item_added', this.libraryItemAdded)
|
this.$socket.$off('item_added', this.libraryItemAdded)
|
||||||
|
|||||||
@@ -319,9 +319,6 @@ export default {
|
|||||||
userIsRoot() {
|
userIsRoot() {
|
||||||
return this.store.getters['user/getIsRoot']
|
return this.store.getters['user/getIsRoot']
|
||||||
},
|
},
|
||||||
_socket() {
|
|
||||||
return this.$root.socket || this.$nuxt.$root.socket
|
|
||||||
},
|
|
||||||
titleFontSize() {
|
titleFontSize() {
|
||||||
return 0.75 * this.sizeMultiplier
|
return 0.75 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
@@ -404,12 +401,13 @@ export default {
|
|||||||
// Server books may have a local library item
|
// Server books may have a local library item
|
||||||
this.localLibraryItem = localLibraryItem
|
this.localLibraryItem = localLibraryItem
|
||||||
},
|
},
|
||||||
clickCard(e) {
|
async clickCard(e) {
|
||||||
if (this.isSelectionMode) {
|
if (this.isSelectionMode) {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
this.selectBtnClick()
|
this.selectBtnClick()
|
||||||
} else if (this.recentEpisode) {
|
} else if (this.recentEpisode) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
var eventBus = this.$eventBus || this.$nuxt.$eventBus
|
||||||
if (this.streamIsPlaying) {
|
if (this.streamIsPlaying) {
|
||||||
eventBus.$emit('pause-item')
|
eventBus.$emit('pause-item')
|
||||||
@@ -528,9 +526,9 @@ export default {
|
|||||||
this.imageReady = true
|
this.imageReady = true
|
||||||
|
|
||||||
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
|
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
|
||||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
const { naturalWidth, naturalHeight } = this.$refs.cover
|
||||||
var aspectRatio = naturalHeight / naturalWidth
|
const aspectRatio = naturalHeight / naturalWidth
|
||||||
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
|
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 image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||||
if (arDiff > 0.15) {
|
if (arDiff > 0.15) {
|
||||||
|
|||||||
@@ -63,9 +63,6 @@ export default {
|
|||||||
var router = this.$router || this.$nuxt.$router
|
var router = this.$router || this.$nuxt.$router
|
||||||
router.push(`/collection/${this.collection.id}`)
|
router.push(`/collection/${this.collection.id}`)
|
||||||
},
|
},
|
||||||
clickEdit() {
|
|
||||||
this.$emit('edit', this.collection)
|
|
||||||
},
|
|
||||||
destroy() {
|
destroy() {
|
||||||
// destroy the vue listeners, etc
|
// destroy the vue listeners, etc
|
||||||
this.$destroy()
|
this.$destroy()
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<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 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="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 -->
|
<!-- 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 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 class="absolute cover-bg" ref="coverBg" />
|
||||||
</div>
|
</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 }" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- No progress shown for collapsed series or podcasts in library -->
|
<!-- 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>
|
||||||
<div class="flex-grow px-2">
|
<div class="flex-grow px-2">
|
||||||
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
<p class="whitespace-normal" :style="{ fontSize: 0.8 * sizeMultiplier + 'rem' }">
|
||||||
@@ -76,6 +76,9 @@ export default {
|
|||||||
this.libraryItem = newVal
|
this.libraryItem = newVal
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
bookCoverAspectRatio() {
|
||||||
|
this.setCSSProperties()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -278,9 +281,6 @@ export default {
|
|||||||
userIsRoot() {
|
userIsRoot() {
|
||||||
return this.store.getters['user/getIsRoot']
|
return this.store.getters['user/getIsRoot']
|
||||||
},
|
},
|
||||||
_socket() {
|
|
||||||
return this.$root.socket || this.$nuxt.$root.socket
|
|
||||||
},
|
|
||||||
titleFontSize() {
|
titleFontSize() {
|
||||||
return 0.75 * this.sizeMultiplier
|
return 0.75 * this.sizeMultiplier
|
||||||
},
|
},
|
||||||
@@ -316,6 +316,9 @@ export default {
|
|||||||
if (!this.isAlternativeBookshelfView) return 0
|
if (!this.isAlternativeBookshelfView) return 0
|
||||||
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
else if (!this.displaySortLine) return 3 * this.sizeMultiplier
|
||||||
return 4.25 * this.sizeMultiplier
|
return 4.25 * this.sizeMultiplier
|
||||||
|
},
|
||||||
|
coverWidth() {
|
||||||
|
return 80 / this.bookCoverAspectRatio
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -436,9 +439,9 @@ export default {
|
|||||||
this.imageReady = true
|
this.imageReady = true
|
||||||
|
|
||||||
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
|
if (this.$refs.cover && this.bookCoverSrc !== this.placeholderUrl) {
|
||||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
const { naturalWidth, naturalHeight } = this.$refs.cover
|
||||||
var aspectRatio = naturalHeight / naturalWidth
|
const aspectRatio = naturalHeight / naturalWidth
|
||||||
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
|
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 image aspect ratio is <= 1.45 or >= 1.75 then use cover bg, otherwise stretch to fit
|
||||||
if (arDiff > 0.15) {
|
if (arDiff > 0.15) {
|
||||||
@@ -448,9 +451,13 @@ export default {
|
|||||||
this.showCoverBg = false
|
this.showCoverBg = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
setCSSProperties() {
|
||||||
|
document.documentElement.style.setProperty('--list-card-cover-width', this.coverWidth + 'px')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
|
this.setCSSProperties()
|
||||||
if (this.bookMount) {
|
if (this.bookMount) {
|
||||||
this.setEntity(this.bookMount)
|
this.setEntity(this.bookMount)
|
||||||
|
|
||||||
@@ -461,3 +468,17 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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() {
|
seriesId() {
|
||||||
return this.series ? this.series.id : null
|
return this.series ? this.series.id : null
|
||||||
},
|
|
||||||
hasValidCovers() {
|
|
||||||
var validCovers = this.books.map((bookItem) => bookItem.book.cover)
|
|
||||||
return !!validCovers.length
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async connectToServer(config) {
|
async connectToServer(config) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
console.log('[ServerConnectForm] connectToServer', config.address)
|
console.log('[ServerConnectForm] connectToServer', config.address)
|
||||||
this.processing = true
|
this.processing = true
|
||||||
this.serverConfig = {
|
this.serverConfig = {
|
||||||
@@ -159,6 +160,7 @@ export default {
|
|||||||
},
|
},
|
||||||
async removeServerConfigClick() {
|
async removeServerConfigClick() {
|
||||||
if (!this.serverConfig.id) return
|
if (!this.serverConfig.id) return
|
||||||
|
await this.$hapticsImpact()
|
||||||
|
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Confirm',
|
title: 'Confirm',
|
||||||
@@ -189,7 +191,8 @@ export default {
|
|||||||
this.showForm = true
|
this.showForm = true
|
||||||
this.showAuth = true
|
this.showAuth = true
|
||||||
},
|
},
|
||||||
newServerConfigClick() {
|
async newServerConfigClick() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.serverConfig = {
|
this.serverConfig = {
|
||||||
address: '',
|
address: '',
|
||||||
userId: '',
|
userId: '',
|
||||||
@@ -348,4 +351,4 @@ export default {
|
|||||||
this.init()
|
this.init()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -47,7 +47,8 @@ export default {
|
|||||||
default: 120
|
default: 120
|
||||||
},
|
},
|
||||||
bookCoverAspectRatio: Number,
|
bookCoverAspectRatio: Number,
|
||||||
downloadCover: String
|
downloadCover: String,
|
||||||
|
raw: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -115,7 +116,7 @@ export default {
|
|||||||
if (this.downloadCover) return this.downloadCover
|
if (this.downloadCover) return this.downloadCover
|
||||||
if (!this.libraryItem) return null
|
if (!this.libraryItem) return null
|
||||||
var store = this.$store || this.$nuxt.$store
|
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() {
|
cover() {
|
||||||
return this.media.coverPath || this.placeholderUrl
|
return this.media.coverPath || this.placeholderUrl
|
||||||
@@ -168,6 +169,8 @@ export default {
|
|||||||
this.showCoverBg = false
|
this.showCoverBg = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.$emit('imageLoaded', this.fullCoverUrl)
|
||||||
},
|
},
|
||||||
imageError(err) {
|
imageError(err) {
|
||||||
this.loading = false
|
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>
|
<template>
|
||||||
<div class="w-full h-9 bg-bg relative">
|
<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">
|
<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'">
|
<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>{{ item.text }}</p>
|
<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>
|
</nuxt-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -14,43 +16,114 @@ export default {
|
|||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {
|
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() {
|
items() {
|
||||||
|
let items = []
|
||||||
if (this.isPodcast) {
|
if (this.isPodcast) {
|
||||||
return [
|
items = [
|
||||||
{
|
{
|
||||||
to: '/bookshelf',
|
to: '/bookshelf',
|
||||||
routeName: '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'
|
text: 'Home'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: '/bookshelf/library',
|
to: '/bookshelf/library',
|
||||||
routeName: 'bookshelf-library',
|
routeName: 'bookshelf-library',
|
||||||
|
iconPack: 'abs-icons',
|
||||||
|
icon: this.currentLibraryIcon,
|
||||||
|
iconClass: 'text-lg',
|
||||||
text: 'Library'
|
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 [
|
|
||||||
{
|
if (this.userHasPlaylists) {
|
||||||
to: '/bookshelf',
|
items.push({
|
||||||
routeName: 'bookshelf',
|
to: '/bookshelf/playlists',
|
||||||
text: 'Home'
|
routeName: 'bookshelf-playlists',
|
||||||
},
|
iconPack: 'material-icons',
|
||||||
{
|
icon: 'queue_music',
|
||||||
to: '/bookshelf/library',
|
text: 'Playlists'
|
||||||
routeName: 'bookshelf-library',
|
})
|
||||||
text: 'Library'
|
}
|
||||||
},
|
|
||||||
{
|
return items
|
||||||
to: '/bookshelf/series',
|
|
||||||
routeName: 'bookshelf-series',
|
|
||||||
text: 'Series'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
to: '/bookshelf/collections',
|
|
||||||
routeName: 'bookshelf-collections',
|
|
||||||
text: 'Collections'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
routeName() {
|
routeName() {
|
||||||
return this.$route.name
|
return this.$route.name
|
||||||
@@ -62,7 +135,9 @@ export default {
|
|||||||
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
return this.$store.getters['libraries/getCurrentLibraryMediaType']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {},
|
methods: {
|
||||||
|
isSelected(item) {}
|
||||||
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<p v-show="!selectedSeriesName" class="font-book pt-1">{{ totalEntities }} {{ entityTitle }}</p>
|
<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>
|
<p v-show="selectedSeriesName" class="ml-2 font-book pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
||||||
<div class="flex-grow" />
|
<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'">
|
<template v-if="page === 'library'">
|
||||||
<div class="relative flex items-center px-2">
|
<div class="relative flex items-center px-2">
|
||||||
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
<span class="material-icons" @click="showFilterModal = true">filter_alt</span>
|
||||||
@@ -64,6 +64,8 @@ export default {
|
|||||||
return 'Series'
|
return 'Series'
|
||||||
} else if (this.page === 'collections') {
|
} else if (this.page === 'collections') {
|
||||||
return 'Collections'
|
return 'Collections'
|
||||||
|
} else if (this.page === 'playlists') {
|
||||||
|
return 'Playlists'
|
||||||
}
|
}
|
||||||
return ''
|
return ''
|
||||||
},
|
},
|
||||||
@@ -85,8 +87,7 @@ export default {
|
|||||||
this.saveSettings()
|
this.saveSettings()
|
||||||
},
|
},
|
||||||
saveSettings() {
|
saveSettings() {
|
||||||
this.$store.commit('user/setSettings', this.settings) // Immediate update
|
this.$store.dispatch('user/updateUserSettings', this.settings)
|
||||||
this.$store.dispatch('user/updateUserSettings', this.settings) // TODO: No need to update settings on server...
|
|
||||||
},
|
},
|
||||||
async init() {
|
async init() {
|
||||||
this.bookshelfListView = await this.$localStore.getBookshelfListView()
|
this.bookshelfListView = await this.$localStore.getBookshelfListView()
|
||||||
@@ -100,16 +101,20 @@ export default {
|
|||||||
},
|
},
|
||||||
setTotalEntities(total) {
|
setTotalEntities(total) {
|
||||||
this.totalEntities = total
|
this.totalEntities = total
|
||||||
|
},
|
||||||
|
async changeView() {
|
||||||
|
this.bookshelfListView = !this.bookshelfListView
|
||||||
|
await this.$hapticsImpact()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.init()
|
this.init()
|
||||||
this.$eventBus.$on('bookshelf-total-entities', this.setTotalEntities)
|
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() {
|
beforeDestroy() {
|
||||||
this.$eventBus.$off('bookshelf-total-entities', this.setTotalEntities)
|
this.$eventBus.$off('bookshelf-total-entities', this.setTotalEntities)
|
||||||
this.$store.commit('user/removeSettingsListener', 'bookshelftoolbar')
|
this.$eventBus.$off('user-settings', this.settingsUpdated)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -118,4 +123,4 @@ export default {
|
|||||||
#bookshelf-toolbar {
|
#bookshelf-toolbar {
|
||||||
box-shadow: 0px 5px 5px #11111155;
|
box-shadow: 0px 5px 5px #11111155;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { Dialog } from '@capacitor/dialog'
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
value: Boolean,
|
value: Boolean,
|
||||||
@@ -94,6 +95,7 @@ export default {
|
|||||||
this.showBookmarkTitleInput = true
|
this.showBookmarkTitleInput = true
|
||||||
},
|
},
|
||||||
async deleteBookmark(bm) {
|
async deleteBookmark(bm) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
const { value } = await Dialog.confirm({
|
const { value } = await Dialog.confirm({
|
||||||
title: 'Remove Bookmark',
|
title: 'Remove Bookmark',
|
||||||
message: `Are you sure you want to remove bookmark?`
|
message: `Are you sure you want to remove bookmark?`
|
||||||
@@ -111,7 +113,8 @@ export default {
|
|||||||
})
|
})
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
clickBookmark(bm) {
|
async clickBookmark(bm) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.$emit('select', bm)
|
this.$emit('select', bm)
|
||||||
},
|
},
|
||||||
submitUpdateBookmark(updatedBookmark) {
|
submitUpdateBookmark(updatedBookmark) {
|
||||||
@@ -155,7 +158,8 @@ export default {
|
|||||||
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
this.newBookmarkTitle = this.$formatDate(Date.now(), 'MMM dd, yyyy HH:mm')
|
||||||
this.showBookmarkTitleInput = true
|
this.showBookmarkTitleInput = true
|
||||||
},
|
},
|
||||||
submitBookmark() {
|
async submitBookmark() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.selectedBookmark) {
|
if (this.selectedBookmark) {
|
||||||
var updatePayload = {
|
var updatePayload = {
|
||||||
...this.selectedBookmark,
|
...this.selectedBookmark,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" :width="300" height="100%">
|
<modals-modal v-model="show" :width="350" height="100%">
|
||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-if="currentChapter" class="absolute top-7 left-4 z-40" style="max-width: 80%">
|
<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>
|
<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 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>
|
<div ref="container" class="w-full overflow-x-hidden overflow-y-auto bg-primary rounded-lg border border-white border-opacity-20" style="max-height: 75%" @click.stop>
|
||||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||||
<template v-for="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)">
|
<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">
|
<div class="relative flex items-center pl-3 pr-20">
|
||||||
<p class="font-normal block truncate text-sm text-white text-opacity-80">{{ chapter.title }}</p>
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
<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)">
|
<li :key="item.value" class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" :class="selected === item.value ? 'bg-success bg-opacity-10' : ''" role="option" @click="clickedOption(item.value)">
|
||||||
<div class="relative flex items-center px-3">
|
<div class="relative flex items-center px-3">
|
||||||
|
<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>
|
<p class="font-normal block truncate text-base text-white text-opacity-80">{{ item.text }}</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
<template #outer>
|
<template #outer>
|
||||||
<div v-show="selected !== 'all'" class="absolute top-4 left-4 z-40">
|
<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>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<div class="w-full h-full overflow-hidden absolute top-0 left-0 flex items-center justify-center" @click="show = false">
|
<div class="w-full 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 || []
|
return this.filterData.languages || []
|
||||||
},
|
},
|
||||||
progress() {
|
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() {
|
sublistItems() {
|
||||||
return (this[this.sublist] || []).map((item) => {
|
return (this[this.sublist] || []).map((item) => {
|
||||||
@@ -196,7 +212,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clearSelected() {
|
async clearSelected() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.selected = 'all'
|
this.selected = 'all'
|
||||||
this.show = false
|
this.show = false
|
||||||
this.$nextTick(() => this.$emit('change', 'all'))
|
this.$nextTick(() => this.$emit('change', 'all'))
|
||||||
@@ -204,7 +221,7 @@ export default {
|
|||||||
clickedSublistOption(item) {
|
clickedSublistOption(item) {
|
||||||
this.clickedOption({ value: `${this.sublist}.${item}` })
|
this.clickedOption({ value: `${this.sublist}.${item}` })
|
||||||
},
|
},
|
||||||
clickedOption(option) {
|
async clickedOption(option) {
|
||||||
if (option.sublist) {
|
if (option.sublist) {
|
||||||
this.sublist = option.value
|
this.sublist = option.value
|
||||||
return
|
return
|
||||||
@@ -215,6 +232,7 @@ export default {
|
|||||||
this.show = false
|
this.show = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.selected = val
|
this.selected = val
|
||||||
this.show = false
|
this.show = false
|
||||||
this.$nextTick(() => this.$emit('change', val))
|
this.$nextTick(() => this.$emit('change', val))
|
||||||
@@ -228,4 +246,4 @@ export default {
|
|||||||
.filter-modal-wrapper {
|
.filter-modal-wrapper {
|
||||||
max-height: calc(100% - 320px);
|
max-height: calc(100% - 320px);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<modals-modal v-model="show" width="100%" height="100%" max-width="100%">
|
<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">
|
<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>
|
</div>
|
||||||
</modals-modal>
|
</modals-modal>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export default {
|
|||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
async clickedOption(lib) {
|
async clickedOption(lib) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.show = false
|
this.show = false
|
||||||
if (lib.id === this.currentLibraryId) return
|
if (lib.id === this.currentLibraryId) return
|
||||||
await this.$store.dispatch('libraries/fetch', lib.id)
|
await this.$store.dispatch('libraries/fetch', lib.id)
|
||||||
|
|||||||
@@ -126,7 +126,8 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickedOption(val) {
|
async clickedOption(val) {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.selected === val) {
|
if (this.selected === val) {
|
||||||
this.selectedDesc = !this.selectedDesc
|
this.selectedDesc = !this.selectedDesc
|
||||||
} else {
|
} else {
|
||||||
@@ -139,4 +140,4 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {}
|
mounted() {}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
currentPlaybackRate: 0,
|
currentPlaybackRate: 0,
|
||||||
MIN_SPEED: 0.5,
|
MIN_SPEED: 0.5,
|
||||||
MAX_SPEED: 3
|
MAX_SPEED: 10
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -71,7 +71,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
rates() {
|
rates() {
|
||||||
return [0.5, 1, 1.2, 1.5, 2]
|
return [0.5, 1, 1.2, 1.5, 2, 3]
|
||||||
},
|
},
|
||||||
canIncrement() {
|
canIncrement() {
|
||||||
return this.playbackRate + 0.1 <= this.MAX_SPEED
|
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>
|
||||||
<li class="text-gray-50 select-none relative py-4 cursor-pointer hover:bg-black-400" role="option" @click="manualTimerModal = true">
|
<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">
|
<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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -89,30 +89,37 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
clickedChapterOption() {
|
async clickedChapterOption() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.show = false
|
this.show = false
|
||||||
this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true }))
|
this.$nextTick(() => this.$emit('change', { time: this.currentEndOfChapterTime * 1000, isChapterTime: true }))
|
||||||
},
|
},
|
||||||
clickedOption(timeoutMin) {
|
async clickedOption(timeoutMin) {
|
||||||
var timeout = timeoutMin * 1000 * 60
|
await this.$hapticsImpact()
|
||||||
|
const timeout = timeoutMin * 1000 * 60
|
||||||
this.show = false
|
this.show = false
|
||||||
this.manualTimerModal = false
|
this.manualTimerModal = false
|
||||||
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
||||||
},
|
},
|
||||||
cancelSleepTimer() {
|
async cancelSleepTimer() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.$emit('cancel')
|
this.$emit('cancel')
|
||||||
this.show = false
|
this.show = false
|
||||||
},
|
},
|
||||||
increaseSleepTime() {
|
async increaseSleepTime() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.$emit('increase')
|
this.$emit('increase')
|
||||||
},
|
},
|
||||||
decreaseSleepTime() {
|
async decreaseSleepTime() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.$emit('decrease')
|
this.$emit('decrease')
|
||||||
},
|
},
|
||||||
increaseManualTimeout() {
|
async increaseManualTimeout() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.manualTimeoutMin++
|
this.manualTimeoutMin++
|
||||||
},
|
},
|
||||||
decreaseManualTimeout() {
|
async decreaseManualTimeout() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.manualTimeoutMin > 1) this.manualTimeoutMin--
|
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>
|
<template>
|
||||||
<div id="epub-frame" class="w-full">
|
<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 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>
|
<p class="text-xs">epub</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
|
|
||||||
@@ -29,8 +29,25 @@ export default {
|
|||||||
hasPrev: false
|
hasPrev: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {},
|
watch: {
|
||||||
|
playerLibraryItemId() {
|
||||||
|
this.updateHeight()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
playerLibraryItemId() {
|
||||||
|
return this.$store.state.playerLibraryItemId
|
||||||
|
},
|
||||||
|
readerHeightOffset() {
|
||||||
|
return this.playerLibraryItemId ? 164 : 64
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
updateHeight() {
|
||||||
|
if (this.rendition && this.rendition.resize) {
|
||||||
|
this.rendition.resize(window.innerWidth, window.innerHeight - this.readerHeightOffset)
|
||||||
|
}
|
||||||
|
},
|
||||||
prev() {
|
prev() {
|
||||||
if (this.rendition) {
|
if (this.rendition) {
|
||||||
this.rendition.prev()
|
this.rendition.prev()
|
||||||
@@ -54,12 +71,12 @@ export default {
|
|||||||
|
|
||||||
this.rendition = book.renderTo('viewer', {
|
this.rendition = book.renderTo('viewer', {
|
||||||
width: window.innerWidth,
|
width: window.innerWidth,
|
||||||
height: window.innerHeight - 64,
|
height: window.innerHeight - this.readerHeightOffset,
|
||||||
snap: true,
|
snap: true,
|
||||||
manager: 'continuous',
|
manager: 'continuous',
|
||||||
flow: 'paginated'
|
flow: 'paginated'
|
||||||
})
|
})
|
||||||
var displayed = this.rendition.display()
|
const displayed = this.rendition.display()
|
||||||
|
|
||||||
book.ready
|
book.ready
|
||||||
.then(() => {
|
.then(() => {
|
||||||
@@ -127,4 +144,9 @@ export default {
|
|||||||
max-height: calc(100% - 32px);
|
max-height: calc(100% - 32px);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.reader-player-open #epub-frame {
|
||||||
|
height: calc(100% - 132px);
|
||||||
|
max-height: calc(100% - 132px);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="ebook-viewer w-full h-full">
|
<div class="mobi-ebook-viewer w-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">
|
<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-screen"> Loading </iframe>
|
<iframe title="html-viewer" class="w-full overflow-hidden"> Loading </iframe>
|
||||||
</div>
|
</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>
|
<p class="text-xs">mobi</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
</div>
|
</div>
|
||||||
@@ -22,7 +22,14 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
},
|
},
|
||||||
computed: {},
|
computed: {
|
||||||
|
playerLibraryItemId() {
|
||||||
|
return this.$store.state.playerLibraryItemId
|
||||||
|
},
|
||||||
|
readerHeightOffset() {
|
||||||
|
return this.playerLibraryItemId ? 164 : 64
|
||||||
|
}
|
||||||
|
},
|
||||||
methods: {
|
methods: {
|
||||||
addHtmlCss() {
|
addHtmlCss() {
|
||||||
let iframe = document.getElementsByTagName('iframe')[0]
|
let iframe = document.getElementsByTagName('iframe')[0]
|
||||||
@@ -114,7 +121,14 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.ebook-viewer {
|
.mobi-ebook-viewer {
|
||||||
height: calc(100% - 32px);
|
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>
|
</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>
|
<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">
|
<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>
|
<p class="w-5/6 truncate">{{ title }}</p>
|
||||||
<div class="flex-grow" />
|
<div class="flex-grow" />
|
||||||
@@ -14,7 +14,10 @@ export default {
|
|||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
touchstartX: 0,
|
touchstartX: 0,
|
||||||
touchendX: 0
|
touchstartY: 0,
|
||||||
|
touchendX: 0,
|
||||||
|
touchendY: 0,
|
||||||
|
touchstartTime: 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -53,6 +56,7 @@ export default {
|
|||||||
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
if (this.ebookType === 'epub') return 'readers-epub-reader'
|
||||||
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
else if (this.ebookType === 'mobi') return 'readers-mobi-reader'
|
||||||
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
else if (this.ebookType === 'comic') return 'readers-comic-reader'
|
||||||
|
else if (this.ebookType === 'pdf') return 'readers-pdf-reader'
|
||||||
return null
|
return null
|
||||||
},
|
},
|
||||||
folderId() {
|
folderId() {
|
||||||
@@ -89,15 +93,22 @@ export default {
|
|||||||
},
|
},
|
||||||
ebookUrl() {
|
ebookUrl() {
|
||||||
if (!this.ebookFile) return null
|
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
|
filepath = this.$encodeUriPath(`${itemRelPath}/${relPath}`)
|
||||||
if (itemRelPath.startsWith('/')) itemRelPath = itemRelPath.slice(1)
|
}
|
||||||
|
const serverAddress = this.$store.getters['user/getServerAddress']
|
||||||
var relPath = this.ebookFile.metadata.relPath
|
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${filepath}`
|
||||||
if (relPath.startsWith('/')) relPath = relPath.slice(1)
|
},
|
||||||
|
playerLibraryItemId() {
|
||||||
var serverAddress = this.$store.getters['user/getServerAddress']
|
return this.$store.state.playerLibraryItemId
|
||||||
return `${serverAddress}/ebook/${this.libraryId}/${this.folderId}/${itemRelPath}/${relPath}`
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
@@ -112,9 +123,19 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
handleGesture() {
|
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) {
|
if (this.touchendX < this.touchstartX) {
|
||||||
console.log('swiped left')
|
console.log('swiped left')
|
||||||
|
|
||||||
this.next()
|
this.next()
|
||||||
}
|
}
|
||||||
if (this.touchendX > this.touchstartX) {
|
if (this.touchendX > this.touchstartX) {
|
||||||
@@ -124,9 +145,12 @@ export default {
|
|||||||
},
|
},
|
||||||
touchstart(e) {
|
touchstart(e) {
|
||||||
this.touchstartX = e.changedTouches[0].screenX
|
this.touchstartX = e.changedTouches[0].screenX
|
||||||
|
this.touchstartY = e.changedTouches[0].screenY
|
||||||
|
this.touchstartTime = Date.now()
|
||||||
},
|
},
|
||||||
touchend(e) {
|
touchend(e) {
|
||||||
this.touchendX = e.changedTouches[0].screenX
|
this.touchendX = e.changedTouches[0].screenX
|
||||||
|
this.touchendY = e.changedTouches[0].screenY
|
||||||
this.handleGesture()
|
this.handleGesture()
|
||||||
},
|
},
|
||||||
registerListeners() {
|
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>
|
<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">
|
<div v-if="episode" class="w-full px-1">
|
||||||
<!-- Help debug for testing -->
|
<!-- Help debug for testing -->
|
||||||
<!-- <template>
|
<!-- <template>
|
||||||
@@ -26,14 +26,28 @@
|
|||||||
|
|
||||||
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
<ui-read-icon-btn :disabled="isProcessingReadUpdate" :is-read="userIsFinished" borderless class="mx-1 mt-0.5" @click="toggleFinished" />
|
||||||
|
|
||||||
|
<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">
|
<div v-if="userCanDownload">
|
||||||
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
<span v-if="isLocal" class="material-icons-outlined px-2 text-success text-lg">audio_file</span>
|
||||||
<span v-else-if="!localEpisode" class="material-icons mx-1 mt-2" :class="downloadItem ? 'animate-bounce text-warning text-opacity-75 text-xl' : 'text-gray-300 text-xl'" @click="downloadClick">{{ downloadItem ? 'downloading' : 'download' }}</span>
|
<span v-else-if="!localEpisode" class="material-icons mx-1.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>
|
<span v-else class="material-icons px-2 text-success text-xl">download_done</span>
|
||||||
</div>
|
</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>
|
</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 v-if="!userIsFinished" class="absolute bottom-0 left-0 h-0.5 bg-warning" :style="{ width: itemProgressPercent * 100 + '%' }" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -58,10 +72,14 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isProcessingReadUpdate: false
|
isProcessingReadUpdate: false,
|
||||||
|
processing: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
isAdminOrUp() {
|
||||||
|
return this.$store.getters['user/getIsAdminOrUp']
|
||||||
|
},
|
||||||
isIos() {
|
isIos() {
|
||||||
return this.$platform === 'ios'
|
return this.$platform === 'ios'
|
||||||
},
|
},
|
||||||
@@ -134,6 +152,32 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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() {
|
async selectFolder() {
|
||||||
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
|
var folderObj = await AbsFileSystem.selectFolder({ mediaType: this.mediaType })
|
||||||
if (folderObj.error) {
|
if (folderObj.error) {
|
||||||
@@ -141,8 +185,9 @@ export default {
|
|||||||
}
|
}
|
||||||
return folderObj
|
return folderObj
|
||||||
},
|
},
|
||||||
downloadClick() {
|
async downloadClick() {
|
||||||
if (this.downloadItem) return
|
if (this.downloadItem) return
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.isIos) {
|
if (this.isIos) {
|
||||||
// no local folders on iOS
|
// no local folders on iOS
|
||||||
this.startDownload()
|
this.startDownload()
|
||||||
@@ -202,7 +247,8 @@ export default {
|
|||||||
this.$toast.error(errorMsg)
|
this.$toast.error(errorMsg)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
playClick() {
|
async playClick() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
if (this.streamIsPlaying) {
|
if (this.streamIsPlaying) {
|
||||||
this.$eventBus.$emit('pause-item')
|
this.$eventBus.$emit('pause-item')
|
||||||
} else {
|
} else {
|
||||||
@@ -224,6 +270,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
async toggleFinished() {
|
async toggleFinished() {
|
||||||
|
await this.$hapticsImpact()
|
||||||
this.isProcessingReadUpdate = true
|
this.isProcessingReadUpdate = true
|
||||||
if (this.isLocal || this.localEpisode) {
|
if (this.isLocal || this.localEpisode) {
|
||||||
var isFinished = !this.userIsFinished
|
var isFinished = !this.userIsFinished
|
||||||
@@ -271,4 +318,4 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,8 +1,32 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full">
|
<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">
|
<div class="flex items-center">
|
||||||
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p>
|
<p class="text-lg mb-1 font-semibold">Episodes ({{ episodesFiltered.length }})</p>
|
||||||
|
|
||||||
<div class="flex-grow" />
|
<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">
|
<button class="outline:none mx-3 pt-0.5 relative" @click="showFilters">
|
||||||
<span class="material-icons text-xl text-gray-200">filter_alt</span>
|
<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" />
|
<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>
|
</div>
|
||||||
|
|
||||||
<template v-for="episode in episodesSorted">
|
<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>
|
</template>
|
||||||
|
|
||||||
<!-- What in tarnation is going on here?
|
<!-- Huhhh?
|
||||||
Without anything below the template it will not re-render -->
|
Without anything below the template it will not re-render -->
|
||||||
<p> </p>
|
<p> </p>
|
||||||
|
|
||||||
<modals-dialog v-model="showFiltersModal" title="Episode Filter" :items="filterItems" :selected="filterKey" @action="setFilter" />
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { Dialog } from '@capacitor/dialog'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
libraryItemId: String,
|
libraryItem: {
|
||||||
|
type: Object,
|
||||||
|
default: () => {}
|
||||||
|
},
|
||||||
episodes: {
|
episodes: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => []
|
default: () => []
|
||||||
@@ -83,7 +114,12 @@ export default {
|
|||||||
text: 'Complete',
|
text: 'Complete',
|
||||||
value: 'complete'
|
value: 'complete'
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
fetchingRSSFeed: false,
|
||||||
|
podcastFeedEpisodes: [],
|
||||||
|
showPodcastEpisodeFeed: false,
|
||||||
|
episodesDownloading: [],
|
||||||
|
episodeDownloadsQueued: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
watch: {
|
watch: {
|
||||||
@@ -95,6 +131,21 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
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() {
|
episodesAreFiltered() {
|
||||||
return this.episodesFiltered.length !== this.episodesCopy.length
|
return this.episodesFiltered.length !== this.episodesCopy.length
|
||||||
},
|
},
|
||||||
@@ -140,9 +191,58 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
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) {
|
setFilter(filter) {
|
||||||
this.filterKey = filter
|
this.filterKey = filter
|
||||||
console.log('Set filter', this.filterKey)
|
|
||||||
this.showFiltersModal = false
|
this.showFiltersModal = false
|
||||||
},
|
},
|
||||||
showFilters() {
|
showFilters() {
|
||||||
@@ -159,8 +259,34 @@ export default {
|
|||||||
this.episodesCopy = this.episodes.map((ep) => {
|
this.episodesCopy = this.episodes.map((ep) => {
|
||||||
return { ...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>
|
</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>
|
<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>
|
<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="flex items-center" :class="!selectedText ? 'text-gray-300' : 'text-white'">
|
||||||
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
|
<span class="block truncate" :class="small ? 'text-sm' : ''">{{ selectedText || placeholder || '' }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -11,9 +11,9 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<transition name="menu">
|
<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">
|
<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">
|
<div class="flex items-center">
|
||||||
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
|
<span class="font-normal ml-3 block truncate font-sans text-sm">{{ item.text }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,11 +42,6 @@ export default {
|
|||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
clickOutsideObj: {
|
|
||||||
handler: this.clickedOutside,
|
|
||||||
events: ['mousedown'],
|
|
||||||
isActive: true
|
|
||||||
},
|
|
||||||
showMenu: false
|
showMenu: false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -66,7 +61,7 @@ export default {
|
|||||||
return this.selectedItem ? this.selectedItem.text : ''
|
return this.selectedItem ? this.selectedItem.text : ''
|
||||||
},
|
},
|
||||||
buttonClass() {
|
buttonClass() {
|
||||||
var classes = []
|
const classes = []
|
||||||
if (this.small) classes.push('h-9')
|
if (this.small) classes.push('h-9')
|
||||||
else classes.push('h-10')
|
else classes.push('h-10')
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<slot />
|
<slot />
|
||||||
</div>
|
</div>
|
||||||
<transition name="menu">
|
<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">
|
<template v-for="item in items">
|
||||||
<nuxt-link :key="item.value" v-if="item.to" :to="item.to">
|
<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)">
|
<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>
|
<template>
|
||||||
<div class="relative">
|
<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">
|
<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>
|
<span class="material-icons text-lg">{{ prependIcon }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -14,6 +20,7 @@ export default {
|
|||||||
placeholder: String,
|
placeholder: String,
|
||||||
type: String,
|
type: String,
|
||||||
disabled: Boolean,
|
disabled: Boolean,
|
||||||
|
readonly: Boolean,
|
||||||
borderless: Boolean,
|
borderless: Boolean,
|
||||||
bg: {
|
bg: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -27,10 +34,11 @@ export default {
|
|||||||
type: String,
|
type: String,
|
||||||
default: null
|
default: null
|
||||||
},
|
},
|
||||||
textSize: {
|
appendIcon: {
|
||||||
type: String,
|
type: String,
|
||||||
default: 'lg'
|
default: null
|
||||||
}
|
},
|
||||||
|
clearable: Boolean
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {}
|
return {}
|
||||||
@@ -45,7 +53,7 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
inputClass() {
|
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')
|
if (this.disabled) classes.push('text-gray-300')
|
||||||
else classes.push('text-white')
|
else classes.push('text-white')
|
||||||
|
|
||||||
@@ -57,6 +65,9 @@ export default {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
clear() {
|
||||||
|
this.input = ''
|
||||||
|
},
|
||||||
focus() {
|
focus() {
|
||||||
if (this.$refs.input) {
|
if (this.$refs.input) {
|
||||||
this.$refs.input.focus()
|
this.$refs.input.focus()
|
||||||
@@ -71,4 +82,10 @@ export default {
|
|||||||
},
|
},
|
||||||
mounted() {}
|
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: {},
|
computed: {},
|
||||||
methods: {
|
methods: {
|
||||||
updateProgress() {
|
updateProgress() {
|
||||||
var progbar = this.$refs.progressbar
|
const progbar = this.$refs.progressbar
|
||||||
var circle = this.$refs.circle
|
const circle = this.$refs.circle
|
||||||
if (!progbar || !circle) return
|
if (!progbar || !circle) return
|
||||||
|
|
||||||
clearTimeout(this.updateTimeout)
|
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-before', this.lastProgress)
|
||||||
progbar.style.setProperty('--progress-percent', progress)
|
progbar.style.setProperty('--progress-percent', progress)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="numPartsRemaining > 0">
|
<div v-if="downloadItemPartsRemaining.length" @click="clickedIt">
|
||||||
<widgets-circle-progress :value="progress" :count="numPartsRemaining" />
|
<widgets-circle-progress :value="progress" :count="downloadItemPartsRemaining.length" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -10,73 +10,42 @@ import { AbsDownloader } from '@/plugins/capacitor'
|
|||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
updateListener: null,
|
downloadItemListener: null,
|
||||||
completeListener: null,
|
completeListener: null,
|
||||||
itemDownloadingMap: {}
|
itemPartUpdateListener: null
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
numItemPartsComplete() {
|
downloadItems() {
|
||||||
var total = 0
|
return this.$store.state.globals.itemDownloads
|
||||||
Object.values(this.itemDownloadingMap).map((item) => (total += item.partsCompleted))
|
|
||||||
return total
|
|
||||||
},
|
},
|
||||||
numPartsRemaining() {
|
downloadItemParts() {
|
||||||
return this.numTotalParts - this.numItemPartsComplete
|
let parts = []
|
||||||
|
this.downloadItems.forEach((di) => parts.push(...di.downloadItemParts))
|
||||||
|
return parts
|
||||||
},
|
},
|
||||||
numTotalParts() {
|
downloadItemPartsRemaining() {
|
||||||
var total = 0
|
return this.downloadItemParts.filter((dip) => !dip.completed)
|
||||||
Object.values(this.itemDownloadingMap).map((item) => (total += item.totalParts))
|
|
||||||
return total
|
|
||||||
},
|
},
|
||||||
progress() {
|
progress() {
|
||||||
var numItems = Object.keys(this.itemDownloadingMap).length
|
let totalBytes = 0
|
||||||
if (!numItems) return 0
|
let totalBytesDownloaded = 0
|
||||||
var totalProg = 0
|
this.downloadItemParts.forEach((dip) => {
|
||||||
Object.values(this.itemDownloadingMap).map((item) => (totalProg += item.itemProgress))
|
totalBytes += dip.fileSize
|
||||||
return totalProg / numItems
|
totalBytesDownloaded += dip.bytesDownloaded
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!totalBytes) return 0
|
||||||
|
return Math.min(1, totalBytesDownloaded / totalBytes)
|
||||||
|
},
|
||||||
|
isIos() {
|
||||||
|
return this.$platform === 'ios'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
onItemDownloadUpdate(data) {
|
clickedIt() {
|
||||||
console.log('DownloadProgressIndicator onItemDownloadUpdate', JSON.stringify(data))
|
if (this.isIos) return // TODO: Implement on iOS
|
||||||
if (!data || !data.downloadItemParts) {
|
this.$router.push('/downloading')
|
||||||
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)
|
|
||||||
},
|
},
|
||||||
onItemDownloadComplete(data) {
|
onItemDownloadComplete(data) {
|
||||||
console.log('DownloadProgressIndicator onItemDownloadComplete', JSON.stringify(data))
|
console.log('DownloadProgressIndicator onItemDownloadComplete', JSON.stringify(data))
|
||||||
@@ -85,11 +54,6 @@ export default {
|
|||||||
return
|
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) {
|
if (!data.localLibraryItem) {
|
||||||
this.$toast.error('Item download complete but failed to create library item')
|
this.$toast.error('Item download complete but failed to create library item')
|
||||||
} else {
|
} else {
|
||||||
@@ -103,15 +67,28 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.$store.commit('globals/removeItemDownload', data.libraryItemId)
|
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() {
|
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))
|
this.completeListener = AbsDownloader.addListener('onItemDownloadComplete', (data) => this.onItemDownloadComplete(data))
|
||||||
},
|
},
|
||||||
beforeDestroy() {
|
beforeDestroy() {
|
||||||
if (this.updateListener) this.updateListener.remove()
|
if (this.downloadItemListener) this.downloadItemListener.remove()
|
||||||
if (this.completeListener) this.completeListener.remove()
|
if (this.completeListener) this.completeListener.remove()
|
||||||
|
if (this.itemPartUpdateListener) this.itemPartUpdateListener.remove()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -147,6 +147,16 @@ export default {
|
|||||||
margin-top: -2px;
|
margin-top: -2px;
|
||||||
margin-left: -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 {
|
.la-ball-spin-clockwise.la-2x {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
|
|||||||
@@ -651,7 +651,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
@@ -705,7 +705,7 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 12.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
SDKROOT = iphoneos;
|
SDKROOT = iphoneos;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
|
||||||
@@ -720,12 +720,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 16;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
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\"";
|
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
@@ -744,12 +744,12 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 16;
|
CURRENT_PROJECT_VERSION = 18;
|
||||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||||
INFOPLIST_FILE = App/Info.plist;
|
INFOPLIST_FILE = App/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
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_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||||
@@ -768,7 +768,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
@@ -790,7 +790,7 @@
|
|||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
GENERATE_INFOPLIST_FILE = YES;
|
GENERATE_INFOPLIST_FILE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 16.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.AudiobookshelfUnitTests;
|
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.AudiobookshelfUnitTests;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
// Override point for customization after application launch.
|
// Override point for customization after application launch.
|
||||||
|
|
||||||
let configuration = Realm.Configuration(
|
let configuration = Realm.Configuration(
|
||||||
schemaVersion: 4,
|
schemaVersion: 6,
|
||||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||||
if (oldSchemaVersion < 1) {
|
if (oldSchemaVersion < 1) {
|
||||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||||
@@ -30,6 +30,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
indexCounter += 1
|
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
|
Realm.Configuration.defaultConfiguration = configuration
|
||||||
@@ -82,15 +94,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
|||||||
backgroundCompletionHandler = completionHandler
|
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>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>NSAppTransportSecurity</key>
|
|
||||||
<dict>
|
|
||||||
<key>NSAllowsArbitraryLoads</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
|
||||||
<key>NSUserActivityTypes</key>
|
<key>NSUserActivityTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>INPlayMediaIntent</string>
|
<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">
|
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||||
<access origin="*" />
|
<access origin="*" />
|
||||||
|
|
||||||
|
<feature name="CDVOrientation">
|
||||||
|
<param name="ios-package" value="CDVOrientation"/>
|
||||||
|
</feature>
|
||||||
|
|
||||||
|
|
||||||
</widget>
|
</widget>
|
||||||
@@ -26,6 +26,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
|||||||
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
NotificationCenter.default.addObserver(self, selector: #selector(onLocalMediaProgressUpdate), name: NSNotification.Name(PlayerEvents.localProgress.rawValue), object: nil)
|
||||||
|
|
||||||
self.bridge?.webView?.allowsBackForwardNavigationGestures = true;
|
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 enableAltView = call.getBool("enableAltView") ?? false
|
||||||
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
|
let jumpBackwardsTime = call.getInt("jumpBackwardsTime") ?? 10
|
||||||
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
|
let jumpForwardTime = call.getInt("jumpForwardTime") ?? 10
|
||||||
|
let lockOrientation = call.getString("lockOrientation") ?? "NONE"
|
||||||
|
let hapticFeedback = call.getString("hapticFeedback") ?? "LIGHT"
|
||||||
let settings = DeviceSettings()
|
let settings = DeviceSettings()
|
||||||
settings.disableAutoRewind = disableAutoRewind
|
settings.disableAutoRewind = disableAutoRewind
|
||||||
settings.enableAltView = enableAltView
|
settings.enableAltView = enableAltView
|
||||||
settings.jumpBackwardsTime = jumpBackwardsTime
|
settings.jumpBackwardsTime = jumpBackwardsTime
|
||||||
settings.jumpForwardTime = jumpForwardTime
|
settings.jumpForwardTime = jumpForwardTime
|
||||||
|
settings.lockOrientation = lockOrientation
|
||||||
|
settings.hapticFeedback = hapticFeedback
|
||||||
|
|
||||||
Database.shared.setDeviceSettings(deviceSettings: settings)
|
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