Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff77dbf3f8 | ||
|
|
3f78fb7a14 | ||
|
|
152904b7c7 | ||
|
|
9214fecc86 | ||
|
|
049397f7ab | ||
|
|
0f42a411a9 | ||
|
|
4575c61118 | ||
|
|
7575a4f883 | ||
|
|
8d4c322bce | ||
|
|
28989f536a | ||
|
|
3318f3501c | ||
|
|
c9ad0c7897 | ||
|
|
018a988124 | ||
|
|
750c370692 | ||
|
|
08ee08c698 | ||
|
|
d899fd4d89 | ||
|
|
aab44d8cee | ||
|
|
2c2fac6e34 | ||
|
|
47ccc54749 | ||
|
|
05d7d0d0d8 | ||
|
|
210fb7b88f | ||
|
|
4eeb7f24d8 | ||
|
|
9496ee1d97 | ||
|
|
f4b05a3236 | ||
|
|
b73763a9ec | ||
|
|
e3aad8c44f | ||
|
|
1e7c298aa2 | ||
|
|
b9652c7cd8 | ||
|
|
04f00d2994 | ||
|
|
3da6728f4c | ||
|
|
5704ca2ae8 | ||
|
|
6cd04e7098 | ||
|
|
290c3560d2 | ||
|
|
40bda3d48b | ||
|
|
63a838dc9d | ||
|
|
127bf7d4e0 | ||
|
|
9af4947232 | ||
|
|
3233a2a078 | ||
|
|
28ede56c7c | ||
|
|
5f6c1f57b0 | ||
|
|
ec704bf501 | ||
|
|
5e0c740f02 | ||
|
|
a185c3abe9 | ||
|
|
e8c6fc635f | ||
|
|
f362db3855 | ||
|
|
9ae584665e | ||
|
|
1aa852c3dc | ||
|
|
ef46ad7a93 | ||
|
|
cbd74d08e1 | ||
|
|
851d0357a0 | ||
|
|
fb5c23d190 | ||
|
|
1743d0e13f | ||
|
|
25af0c4170 | ||
|
|
4bcb77250d | ||
|
|
6114e03cb7 | ||
|
|
9866a787bd | ||
|
|
20e64523b2 | ||
|
|
50a53ec0d1 | ||
|
|
40b2ff5a97 | ||
|
|
48cc000b95 | ||
|
|
75aaf4d784 | ||
|
|
23220ecae6 | ||
|
|
94b4dda94f | ||
|
|
86c5d1a3e9 | ||
|
|
d7be01935f | ||
|
|
b2b647e2f7 | ||
|
|
405cd21c32 | ||
|
|
17a894f5ef | ||
|
|
23c90f4118 | ||
|
|
f215efdcd0 | ||
|
|
2f243787ce | ||
|
|
39909a398e | ||
|
|
5e98a4ff2f | ||
|
|
8710775872 | ||
|
|
99d60eaf9a | ||
|
|
55b7f05685 | ||
|
|
944aa5de66 | ||
|
|
a12eb7efdf | ||
|
|
62a5a2d069 | ||
|
|
ebd65b4fa7 | ||
|
|
152cce2e9e | ||
|
|
ea847a62ff | ||
|
|
2a5ec618f0 | ||
|
|
3fc89377b4 | ||
|
|
93c16e6a62 |
@@ -0,0 +1,81 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang=en>
|
||||
<head>
|
||||
<meta http-equiv=content-type content="text/html; charset=utf-8" />
|
||||
<meta name=viewport content="width=device-width, initial-scale=1">
|
||||
<title>Audiobookshelf :: Testers</title>
|
||||
<style>
|
||||
body {
|
||||
background-color: rgb(55, 56, 56);
|
||||
font-family: Sans;
|
||||
margin: 40px auto;
|
||||
max-width: 500px;
|
||||
color: #ddd;
|
||||
text-align: center;
|
||||
padding: 5px;
|
||||
}
|
||||
img {
|
||||
width: 100px;
|
||||
}
|
||||
h1 {
|
||||
font-weight: 100;
|
||||
}
|
||||
a {
|
||||
color: #fff;
|
||||
}
|
||||
#apk {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto;
|
||||
justify-content: center;
|
||||
column-gap: 15px;
|
||||
text-align: left;
|
||||
font-family: monospace;
|
||||
background-color: #222;
|
||||
padding: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<a href="https://audiobookshelf.org">
|
||||
<img src=logo.png />
|
||||
</a>
|
||||
<h1>Audiobookshelf :: Testers</h1>
|
||||
|
||||
<p>
|
||||
Here you can download the latest version of Audiobookshelf for Android for <b>testing</b>.
|
||||
The app is automatically built for every new patch added to the project.
|
||||
</p>
|
||||
|
||||
<div id=apk>
|
||||
<div>Built on</div>
|
||||
<div>__DATE__</div>
|
||||
|
||||
<div>Git commit</div>
|
||||
<a href="https://github.com/advplyr/audiobookshelf-app/commit/__COMMIT__">__COMMIT__</a>
|
||||
|
||||
<div>Download</div>
|
||||
<a href="__APK__">__APK__</a>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
Using this requires some technical knowledge to install the APK manually.
|
||||
Regular users should use the releases published on the <a href="https://play.google.com/store/apps/details?id=com.audiobookshelf.app">Play Store</a>.
|
||||
</p>
|
||||
|
||||
<hr />
|
||||
|
||||
<p>Report bugs, request features, provide feedback, and contribute on <a href="https://github.com/advplyr/audiobookshelf-app/issues">GitHub</a></p>
|
||||
<p>
|
||||
<a href="https://github.com/advplyr/audiobookshelf-app/issues">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width=32 height=32 viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
name: Build APK
|
||||
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- 'ios/**'
|
||||
- 'readme.md'
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- 'ios/**'
|
||||
- 'readme.md'
|
||||
jobs:
|
||||
main:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
|
||||
- name: upload app
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: audiobookshelf-apk
|
||||
path: android/app/build/outputs/apk/debug/*.apk
|
||||
@@ -0,0 +1,76 @@
|
||||
name: Publish Test App
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
paths-ignore:
|
||||
- 'ios/**'
|
||||
- 'readme.md'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: checkout sources
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: use Node.js 16.x
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
|
||||
- name: install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: build Nuxt project
|
||||
run: npm run generate
|
||||
|
||||
- name: copy to Android project
|
||||
run: npx cap sync
|
||||
|
||||
- name: build Android app
|
||||
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||
|
||||
- name: rename apk
|
||||
working-directory: android/app/build/outputs/apk/debug/
|
||||
run: |
|
||||
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||
name="audiobookshelf-${build}.apk"
|
||||
mv -v app-debug.apk "${name}"
|
||||
|
||||
- name: prepare test page ressources
|
||||
run: |
|
||||
mkdir ghpages
|
||||
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
||||
cp static/Logo.png ghpages/logo.png
|
||||
cp .github/testing-page-template.html ghpages/index.html
|
||||
|
||||
- name: build test page
|
||||
working-directory: ghpages
|
||||
run: |
|
||||
sed -i "s/__DATE__/$(date)/g" index.html
|
||||
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
||||
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
||||
|
||||
- name: upload test page artifact
|
||||
uses: actions/upload-pages-artifact@v1
|
||||
with:
|
||||
path: ./ghpages
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
|
||||
permissions:
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v1
|
||||
@@ -35,8 +35,8 @@ android {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 92
|
||||
versionName "0.9.61-beta"
|
||||
versionCode 93
|
||||
versionName "0.9.62-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true" >
|
||||
|
||||
<!-- TODO: Incomplete desktop widget -->
|
||||
<!-- <receiver-->
|
||||
<!-- android:name=".NewAppWidget"-->
|
||||
<!-- android:exported="true" >-->
|
||||
@@ -74,7 +76,7 @@
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="false">
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
@@ -83,7 +85,8 @@
|
||||
<service
|
||||
android:name=".player.PlayerNotificationService"
|
||||
android:enabled="true"
|
||||
android:exported="true" >
|
||||
android:exported="true"
|
||||
android:foregroundServiceType="mediaPlayback">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService" />
|
||||
</intent-filter>
|
||||
|
||||
@@ -102,7 +102,9 @@ data class DeviceSettings(
|
||||
var autoSleepTimer: Boolean,
|
||||
var autoSleepTimerStartTime: String,
|
||||
var autoSleepTimerEndTime: String,
|
||||
var sleepTimerLength: Long // Time in milliseconds
|
||||
var sleepTimerLength: Long, // Time in milliseconds
|
||||
var disableSleepTimerFadeOut: Boolean,
|
||||
var disableSleepTimerResetFeedback: Boolean
|
||||
) {
|
||||
companion object {
|
||||
// Static method to get default device settings
|
||||
@@ -119,7 +121,9 @@ data class DeviceSettings(
|
||||
autoSleepTimer = false,
|
||||
autoSleepTimerStartTime = "22:00",
|
||||
autoSleepTimerEndTime = "06:00",
|
||||
sleepTimerLength = 900000L // 15 minutes
|
||||
sleepTimerLength = 900000L, // 15 minutes
|
||||
disableSleepTimerFadeOut = false,
|
||||
disableSleepTimerResetFeedback = false
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -154,8 +158,8 @@ data class DeviceSettings(
|
||||
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?
|
||||
var deviceSettings: DeviceSettings?,
|
||||
var lastPlaybackSession: PlaybackSession?
|
||||
) {
|
||||
@JsonIgnore
|
||||
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.audiobookshelf.app.data
|
||||
|
||||
import android.util.Log
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import kotlin.math.roundToInt
|
||||
@@ -33,6 +32,11 @@ class LocalMediaProgress(
|
||||
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun isMatch(mediaProgress:MediaProgress):Boolean {
|
||||
if (episodeId != null) return libraryItemId == mediaProgress.libraryItemId && episodeId == mediaProgress.episodeId
|
||||
return libraryItemId == mediaProgress.libraryItemId
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun updateIsFinished(finished:Boolean) {
|
||||
|
||||
@@ -7,7 +7,7 @@ import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
@@ -254,7 +254,7 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
@JsonIgnore
|
||||
fun syncData(syncData:MediaProgressSyncData) {
|
||||
fun syncData(syncData: MediaProgressSyncData) {
|
||||
timeListening += syncData.timeListened
|
||||
updatedAt = System.currentTimeMillis()
|
||||
currentTime = syncData.currentTime
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.audiobookshelf.app.device
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.managers.DbManager
|
||||
@@ -46,4 +49,27 @@ object DeviceManager {
|
||||
if (id == null) return null
|
||||
return deviceData.serverConnectionConfigs.find { it.id == id }
|
||||
}
|
||||
|
||||
fun checkConnectivity(ctx:Context): Boolean {
|
||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
if (capabilities != null) {
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET")
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun setLastPlaybackSession(playbackSession:PlaybackSession) {
|
||||
deviceData.lastPlaybackSession = playbackSession
|
||||
dbManager.saveDeviceData(deviceData)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class FolderScanner(var ctx: Context) {
|
||||
var mediaItemsUpToDate = 0
|
||||
|
||||
// Search for files in media item folder
|
||||
val foldersFound = df.search(false, DocumentFileType.FOLDER)
|
||||
val foldersFound = df.search(true, DocumentFileType.FOLDER)
|
||||
|
||||
// Match folders found with local library items already saved in db
|
||||
var existingLocalLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(localFolder.id)
|
||||
@@ -72,11 +72,16 @@ class FolderScanner(var ctx: Context) {
|
||||
Log.d(tag, "Iterating over Folder Found ${itemFolder.name} | ${itemFolder.getSimplePath(ctx)} | URI: ${itemFolder.uri}")
|
||||
val existingItem = existingLocalLibraryItems.find { emi -> emi.id == getLocalLibraryItemId(itemFolder.id) }
|
||||
|
||||
when (scanLibraryItemFolder(itemFolder, localFolder, existingItem, forceAudioProbe)) {
|
||||
ItemScanResult.REMOVED -> mediaItemsRemoved++
|
||||
ItemScanResult.UPDATED -> mediaItemsUpdated++
|
||||
ItemScanResult.ADDED -> mediaItemsAdded++
|
||||
else -> mediaItemsUpToDate++
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
|
||||
// Do not scan folders that have no media items and not an existing item already
|
||||
if (existingItem != null || filesInFolder.isNotEmpty()) {
|
||||
when (scanLibraryItemFolder(itemFolder, filesInFolder, localFolder, existingItem, forceAudioProbe)) {
|
||||
ItemScanResult.REMOVED -> mediaItemsRemoved++
|
||||
ItemScanResult.UPDATED -> mediaItemsUpdated++
|
||||
ItemScanResult.ADDED -> mediaItemsAdded++
|
||||
else -> mediaItemsUpToDate++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +96,7 @@ class FolderScanner(var ctx: Context) {
|
||||
}
|
||||
}
|
||||
|
||||
private fun scanLibraryItemFolder(itemFolder:DocumentFile, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
||||
private fun scanLibraryItemFolder(itemFolder:DocumentFile, filesInFolder:List<DocumentFile>, localFolder:LocalFolder, existingItem:LocalLibraryItem?, forceAudioProbe:Boolean):ItemScanResult {
|
||||
val itemFolderName = itemFolder.name ?: ""
|
||||
val itemId = getLocalLibraryItemId(itemFolder.id)
|
||||
|
||||
@@ -106,8 +111,6 @@ class FolderScanner(var ctx: Context) {
|
||||
var coverContentUrl:String? = null
|
||||
var coverAbsolutePath:String? = null
|
||||
|
||||
val filesInFolder = itemFolder.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*", "video/mp4", "application/octet-stream"))
|
||||
|
||||
val existingLocalFilesRemoved = existingLocalFiles.filter { elf ->
|
||||
filesInFolder.find { fif -> DeviceManager.getBase64Id(fif.id) == elf.id } == null // File was not found in media item folder
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ class DbManager {
|
||||
}
|
||||
|
||||
fun getDeviceData(): DeviceData {
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, null, DeviceSettings.default())
|
||||
return Paper.book("device").read("data") ?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||
}
|
||||
fun saveDeviceData(deviceData: DeviceData) {
|
||||
Paper.book("device").write("data", deviceData)
|
||||
@@ -237,18 +237,26 @@ class DbManager {
|
||||
}
|
||||
}
|
||||
|
||||
fun saveLocalPlaybackSession(playbackSession: PlaybackSession) {
|
||||
Paper.book("localPlaybackSession").write(playbackSession.id,playbackSession)
|
||||
}
|
||||
fun getLocalPlaybackSession(playbackSessionId:String): PlaybackSession? {
|
||||
return Paper.book("localPlaybackSession").read(playbackSessionId)
|
||||
}
|
||||
|
||||
|
||||
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
|
||||
Paper.book("mediaItemHistory").write(mediaItemHistory.id,mediaItemHistory)
|
||||
}
|
||||
fun getMediaItemHistory(id:String): MediaItemHistory? {
|
||||
return Paper.book("mediaItemHistory").read(id)
|
||||
}
|
||||
|
||||
fun savePlaybackSession(playbackSession: PlaybackSession) {
|
||||
Paper.book("playbackSession").write(playbackSession.id,playbackSession)
|
||||
}
|
||||
fun removePlaybackSession(playbackSessionId:String) {
|
||||
Paper.book("playbackSession").delete(playbackSessionId)
|
||||
}
|
||||
fun getPlaybackSessions():List<PlaybackSession> {
|
||||
val sessions:MutableList<PlaybackSession> = mutableListOf()
|
||||
Paper.book("playbackSession").allKeys.forEach { playbackSessionId ->
|
||||
Paper.book("playbackSession").read<PlaybackSession>(playbackSessionId)?.let {
|
||||
sessions.add(it)
|
||||
}
|
||||
}
|
||||
return sessions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.*
|
||||
|
||||
class DownloadItemManager(var downloadManager:DownloadManager, private var folderScanner: FolderScanner, var mainActivity: MainActivity, private var clientEventEmitter:DownloadEventEmitter) {
|
||||
val tag = "DownloadItemManager"
|
||||
private val maxSimultaneousDownloads = 1
|
||||
private val maxSimultaneousDownloads = 3
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
enum class DownloadCheckStatus {
|
||||
@@ -188,7 +189,8 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
|
||||
// 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) {
|
||||
val docNameLowerCase = resultDocFile.name?.lowercase(Locale.getDefault()) ?: ""
|
||||
if (docNameLowerCase.endsWith(".m4b.mp3")|| docNameLowerCase.endsWith(".m4a.mp3")) {
|
||||
resultDocFile.renameTo(downloadItemPart.filename)
|
||||
}
|
||||
|
||||
@@ -223,23 +225,27 @@ class DownloadItemManager(var downloadManager:DownloadManager, private var folde
|
||||
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}")
|
||||
GlobalScope.launch(Dispatchers.IO) {
|
||||
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)
|
||||
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?.localLibraryItem?.let { localLibraryItem ->
|
||||
jsobj.put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(localLibraryItem)))
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
downloadItemScanResult?.localMediaProgress?.let { localMediaProgress ->
|
||||
jsobj.put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(localMediaProgress)))
|
||||
}
|
||||
|
||||
clientEventEmitter.onDownloadItemComplete(jsobj)
|
||||
downloadItemQueue.remove(downloadItem)
|
||||
DeviceManager.dbManager.removeDownloadItem(downloadItem.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
private var sleepTimerLength:Long = 0L
|
||||
private var sleepTimerElapsed:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
private var isAutoSleepTimer:Boolean = false // When timer was auto-set
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
@@ -54,7 +55,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble()).roundToInt()
|
||||
}
|
||||
|
||||
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
private fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerRunning = true
|
||||
@@ -85,7 +86,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
}
|
||||
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
@@ -96,7 +97,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
Log.d(tag, "Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
|
||||
if (sleepTimeSecondsRemaining > 0) {
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(sleepTimeSecondsRemaining, isAutoSleepTimer)
|
||||
}
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
@@ -107,10 +108,16 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 60) {
|
||||
// Start fading out audio
|
||||
val volume = sleepTimeSecondsRemaining / 60F
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerFadeOut == true) {
|
||||
// Set volume to 1 in case setting was enabled while fading
|
||||
setVolume(1f)
|
||||
} else {
|
||||
// Start fading out audio down to 10% volume
|
||||
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||
val volume = 1f - (percentToReduce * 0.9f)
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -118,11 +125,17 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
return true
|
||||
}
|
||||
|
||||
fun setManualSleepTimer(time: Long, isChapterTime:Boolean):Boolean {
|
||||
isAutoSleepTimer = false
|
||||
return setSleepTimer(time, isChapterTime)
|
||||
}
|
||||
|
||||
private fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
sleepTimerEndTime = 0
|
||||
sleepTimerRunning = false
|
||||
isAutoSleepTimer = false
|
||||
playerNotificationService.unregisterSensor()
|
||||
}
|
||||
|
||||
@@ -132,12 +145,21 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
|
||||
fun cancelSleepTimer() {
|
||||
Log.d(tag, "Canceling Sleep Timer")
|
||||
|
||||
if (isAutoSleepTimer) {
|
||||
Log.i(tag, "Disabling auto sleep timer")
|
||||
DeviceManager.deviceData.deviceSettings?.autoSleepTimer = false
|
||||
DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData)
|
||||
}
|
||||
|
||||
clearSleepTimer()
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||
}
|
||||
|
||||
// Vibrate when extending sleep timer by shaking
|
||||
private fun vibrate() {
|
||||
// Vibrate when resetting sleep timer
|
||||
private fun vibrateFeedback() {
|
||||
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
|
||||
|
||||
val context = playerNotificationService.getContext()
|
||||
val vibrator:Vibrator
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
@@ -187,7 +209,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
private fun resetChapterTimer() {
|
||||
this.getChapterEndTime()?.let { chapterEndTime ->
|
||||
Log.d(tag, "Resetting stopped sleep timer to end of chapter $chapterEndTime")
|
||||
vibrate()
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
@@ -212,7 +234,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
resetChapterTimer()
|
||||
} else {
|
||||
Log.d(tag, "Resetting stopped sleep timer to length $sleepTimerLength")
|
||||
vibrate()
|
||||
vibrateFeedback()
|
||||
setSleepTimer(sleepTimerLength, false)
|
||||
play()
|
||||
}
|
||||
@@ -222,14 +244,14 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
// Does not apply to chapter sleep timers and timer must be running for at least 3 seconds
|
||||
if (sleepTimerLength > 0L && sleepTimerElapsed > 3000L) {
|
||||
Log.d(tag, "Resetting running sleep timer to length $sleepTimerLength")
|
||||
vibrate()
|
||||
vibrateFeedback()
|
||||
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()
|
||||
vibrateFeedback()
|
||||
setSleepTimer(chapterEndTime, true)
|
||||
play()
|
||||
}
|
||||
@@ -264,7 +286,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
}
|
||||
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
@@ -286,7 +308,7 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
}
|
||||
|
||||
setVolume(1F)
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds())
|
||||
playerNotificationService.clientEventEmitter?.onSleepTimerSet(getSleepTimerTimeRemainingSeconds(), isAutoSleepTimer)
|
||||
}
|
||||
|
||||
fun checkAutoSleepTimer() {
|
||||
@@ -322,9 +344,11 @@ class SleepTimerManager constructor(private val playerNotificationService: Playe
|
||||
if (chapterEndTimeMs == null) {
|
||||
Log.e(tag, "Setting auto sleep timer to end of chapter/track but there is no current session")
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(chapterEndTimeMs, true)
|
||||
}
|
||||
} else {
|
||||
isAutoSleepTimer = true
|
||||
setSleepTimer(deviceSettings.sleepTimerLength, false)
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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"
|
||||
@@ -69,7 +68,7 @@ object MediaEventManager {
|
||||
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||
}
|
||||
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult:SyncResult?) {
|
||||
private fun addPlaybackEvent(eventName:String, playbackSession:PlaybackSession, syncResult: SyncResult?) {
|
||||
val mediaItemHistory = getMediaItemHistoryMediaItem(playbackSession.mediaItemId) ?: createMediaItemHistoryForSession(playbackSession)
|
||||
|
||||
val mediaItemEvent = MediaItemEvent(
|
||||
@@ -103,8 +102,7 @@ object MediaEventManager {
|
||||
libraryItemId,
|
||||
episodeId,
|
||||
isLocalOnly,
|
||||
playbackSession.
|
||||
serverConnectionConfigId,
|
||||
playbackSession.serverConnectionConfigId,
|
||||
playbackSession.serverAddress,
|
||||
playbackSession.userId,
|
||||
createdAt = System.currentTimeMillis(),
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.util.*
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import kotlin.coroutines.resume
|
||||
import kotlin.coroutines.suspendCoroutine
|
||||
|
||||
@@ -32,7 +33,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
var serverItemsInProgress = listOf<ItemInProgress>()
|
||||
var serverLibraries = listOf<Library>()
|
||||
|
||||
private var userSettingsPlaybackRate:Float? = null
|
||||
var userSettingsPlaybackRate:Float? = null
|
||||
|
||||
fun getIsLibrary(id:String) : Boolean {
|
||||
return serverLibraries.find { it.id == id } != null
|
||||
@@ -61,12 +62,41 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
return 1f
|
||||
}
|
||||
|
||||
fun setSavedPlaybackRate(newRate: Float) {
|
||||
val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
|
||||
val sharedPrefEditor = sharedPrefs.edit()
|
||||
if (sharedPrefs != null) {
|
||||
val userSettingsPref = sharedPrefs.getString("userSettings", null)
|
||||
if (userSettingsPref != null) {
|
||||
try {
|
||||
val userSettings = JSObject(userSettingsPref)
|
||||
// toString().toDouble() to prevent float conversion issues (ex 1.2f becomes 1.2000000476837158d)
|
||||
userSettings.put("playbackRate", newRate.toString().toDouble())
|
||||
sharedPrefEditor.putString("userSettings", userSettings.toString())
|
||||
sharedPrefEditor.apply()
|
||||
userSettingsPlaybackRate = newRate
|
||||
Log.d(tag, "Saved userSettings JSON from Android Auto with playbackRate=$newRate")
|
||||
} catch(je:JSONException) {
|
||||
Log.e(tag, "Failed to save userSettings JSON ${je.localizedMessage}")
|
||||
}
|
||||
} else {
|
||||
// Not sure if this is the best place for this, but if a user has not changed any user settings in the app
|
||||
// the object will not exist yet, could be moved to a centralized place or created on first app load
|
||||
val userSettings = JSONObject()
|
||||
userSettings.put("playbackRate", newRate.toString().toDouble())
|
||||
sharedPrefEditor.putString("userSettings", userSettings.toString())
|
||||
userSettingsPlaybackRate = newRate
|
||||
Log.d(tag, "Created and saved userSettings JSON from Android Auto with playbackRate=$newRate")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkResetServerItems() {
|
||||
// When opening android auto need to check if still connected to server
|
||||
// and reset any server data already set
|
||||
val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig()
|
||||
|
||||
if (!DeviceManager.isConnectedToServer || !apiHandler.isOnline() || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id !== serverConfigIdUsed) {
|
||||
podcastEpisodeLibraryItemMap = mutableMapOf()
|
||||
serverLibraryCategories = listOf()
|
||||
serverLibraries = listOf()
|
||||
@@ -217,7 +247,7 @@ class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) {
|
||||
Log.d(tag, "checkSetValidServerConnectionConfig | $serverConfigIdUsed")
|
||||
|
||||
coroutineScope {
|
||||
if (!apiHandler.isOnline()) {
|
||||
if (!DeviceManager.checkConnectivity(ctx)) {
|
||||
serverUserMediaProgress = mutableListOf()
|
||||
cb(false)
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package com.audiobookshelf.app.player
|
||||
package com.audiobookshelf.app.media
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
@@ -6,9 +6,8 @@ import android.util.Log
|
||||
import com.audiobookshelf.app.data.LocalMediaProgress
|
||||
import com.audiobookshelf.app.data.MediaProgress
|
||||
import com.audiobookshelf.app.data.PlaybackSession
|
||||
import com.audiobookshelf.app.data.Podcast
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
@@ -25,7 +24,7 @@ data class SyncResult(
|
||||
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 METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||
|
||||
@@ -40,7 +39,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
|
||||
private val currentDisplayTitle get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||
private val currentIsLocal get() = currentPlaybackSession?.isLocal == true
|
||||
val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentSessionId get() = currentPlaybackSession?.id ?: ""
|
||||
private val currentPlaybackDuration get() = currentPlaybackSession?.duration ?: 0.0
|
||||
|
||||
fun start(playbackSession:PlaybackSession) {
|
||||
@@ -62,8 +61,8 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
|
||||
listeningTimerRunning = true
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
Log.d(tag, "start: init last sync time $lastSyncTime")
|
||||
currentPlaybackSession = playbackSession.clone()
|
||||
Log.d(tag, "start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}")
|
||||
|
||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
@@ -199,8 +198,6 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
it.currentTime = mediaProgress.currentTime
|
||||
|
||||
MediaEventManager.syncEvent(mediaProgress, "Received from server get media progress request while playback session open")
|
||||
|
||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||
saveLocalProgress(it)
|
||||
}
|
||||
}
|
||||
@@ -226,16 +223,25 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
return cb(null)
|
||||
}
|
||||
|
||||
val hasNetworkConnection = DeviceManager.checkConnectivity(playerNotificationService)
|
||||
|
||||
// Save playback session to db (server linked sessions only)
|
||||
// Sessions are removed once successfully synced with the server
|
||||
currentPlaybackSession?.let {
|
||||
if (!it.isLocalLibraryItemOnly) {
|
||||
DeviceManager.dbManager.savePlaybackSession(it)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentIsLocal) {
|
||||
// Save local progress sync
|
||||
currentPlaybackSession?.let {
|
||||
DeviceManager.dbManager.saveLocalPlaybackSession(it)
|
||||
saveLocalProgress(it)
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
|
||||
// Local library item is linked to a server library item
|
||||
// Send sync to server also if connected to this server and local item belongs to this server
|
||||
if (shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
if (hasNetworkConnection && shouldSyncServer && !it.libraryItemId.isNullOrEmpty() && it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId) {
|
||||
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||
Log.d(
|
||||
tag,
|
||||
@@ -245,13 +251,14 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
if (syncSuccess) {
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
|
||||
} else {
|
||||
failedSyncs++
|
||||
if (failedSyncs == 2) {
|
||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||
failedSyncs = 0
|
||||
}
|
||||
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 with session id=${it.id}")
|
||||
}
|
||||
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
@@ -260,7 +267,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
cb(SyncResult(false, null, null))
|
||||
}
|
||||
}
|
||||
} else if (shouldSyncServer) {
|
||||
} else if (hasNetworkConnection && shouldSyncServer) {
|
||||
Log.d(tag, "sync: currentSessionId=$currentSessionId")
|
||||
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||
if (syncSuccess) {
|
||||
@@ -268,13 +275,14 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
failedSyncs = 0
|
||||
playerNotificationService.alertSyncSuccess()
|
||||
lastSyncTime = System.currentTimeMillis()
|
||||
DeviceManager.dbManager.removePlaybackSession(currentSessionId) // Remove session from db
|
||||
} else {
|
||||
failedSyncs++
|
||||
if (failedSyncs == 2) {
|
||||
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||
failedSyncs = 0
|
||||
}
|
||||
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 with session id=${currentSessionId}")
|
||||
}
|
||||
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||
}
|
||||
@@ -307,6 +315,7 @@ class MediaProgressSyncer(val playerNotificationService:PlayerNotificationServic
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun reset() {
|
||||
currentPlaybackSession = null
|
||||
currentLocalMediaProgress = null
|
||||
|
||||
@@ -32,7 +32,7 @@ data class DownloadItem(
|
||||
for (it in downloadItemParts) {
|
||||
if (!it.completed && it.downloadId == null) {
|
||||
itemParts.add(it)
|
||||
if (itemParts.size > limit) break
|
||||
if (itemParts.size >= limit) break
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.audiobookshelf.app.models
|
||||
|
||||
import com.audiobookshelf.app.data.MediaProgress
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class User(
|
||||
val id:String,
|
||||
val username: String,
|
||||
val mediaProgress:List<MediaProgress>
|
||||
)
|
||||
@@ -8,6 +8,7 @@ import android.util.Log
|
||||
import android.view.KeyEvent
|
||||
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||
import com.audiobookshelf.app.data.PodcastEpisode
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
@@ -88,6 +89,25 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
playerNotificationService.seekPlayer(pos)
|
||||
}
|
||||
|
||||
private fun onChangeSpeed() {
|
||||
// cycle to next speed, only contains preset android app options, as each increment needs it's own icon
|
||||
// Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app
|
||||
val mediaManager = playerNotificationService.mediaManager
|
||||
val newSpeed = when (mediaManager.getSavedPlaybackRate()) {
|
||||
in 0.5f..0.7f -> 1.0f
|
||||
in 0.8f..1.0f -> 1.2f
|
||||
in 1.1f..1.2f -> 1.5f
|
||||
in 1.3f..1.5f -> 2.0f
|
||||
in 1.6f..2.0f -> 3.0f
|
||||
in 2.1f..3.0f -> 0.5f
|
||||
// anything set above 3 (can happen in the android app) will be reset to 1
|
||||
else -> 1.0f
|
||||
}
|
||||
mediaManager.setSavedPlaybackRate(newSpeed)
|
||||
playerNotificationService.setPlaybackSpeed(newSpeed)
|
||||
playerNotificationService.clientEventEmitter?.onPlaybackSpeedChanged(newSpeed)
|
||||
}
|
||||
|
||||
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
|
||||
val libraryItemWrapper: LibraryItemWrapper?
|
||||
@@ -138,15 +158,36 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
|
||||
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
|
||||
|
||||
// Widget button intent is only sending the action down event
|
||||
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
|
||||
Log.d(tag, "handleCallMediaButton: key action_down for ${keyEvent.keyCode}")
|
||||
when (keyEvent.keyCode) {
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
|
||||
|
||||
// TODO: Play/pause event sent from widget when app is closed. Currently the service gets destroyed before anything can happen
|
||||
// if (playerNotificationService.currentPlaybackSession == null && DeviceManager.deviceData.lastPlaybackSession != null) {
|
||||
// Log.i(tag, "No playback session but had one in the db")
|
||||
//
|
||||
// val connectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == DeviceManager.deviceData.lastPlaybackSession?.serverConnectionConfigId }
|
||||
// connectionConfig?.let {
|
||||
// Log.i(tag, "Setting playback session from db $it")
|
||||
// DeviceManager.serverConnectionConfig = it
|
||||
//
|
||||
// playerNotificationService.currentPlaybackSession = DeviceManager.deviceData.lastPlaybackSession
|
||||
// playerNotificationService.startNewPlaybackSession()
|
||||
// return true
|
||||
// }
|
||||
// }
|
||||
|
||||
if (playerNotificationService.mPlayer.isPlaying) {
|
||||
playerNotificationService.pause()
|
||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||
handleMediaButtonClickCount()
|
||||
} else {
|
||||
playerNotificationService.play()
|
||||
if (0 == mediaButtonClickCount) {
|
||||
playerNotificationService.play()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,18 +233,6 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||
playerNotificationService.closePlayback()
|
||||
}
|
||||
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
|
||||
if (playerNotificationService.mPlayer.isPlaying) {
|
||||
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||
handleMediaButtonClickCount()
|
||||
} else {
|
||||
if (0 == mediaButtonClickCount) {
|
||||
playerNotificationService.play()
|
||||
}
|
||||
handleMediaButtonClickCount()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
|
||||
return false
|
||||
@@ -248,6 +277,7 @@ class MediaSessionCallback(var playerNotificationService:PlayerNotificationServi
|
||||
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
|
||||
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
|
||||
CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_for
|
||||
const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward";
|
||||
const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward";
|
||||
const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward";
|
||||
const val CUSTOM_ACTION_CHANGE_SPEED = "com.audiobookshelf.customAction.change_speed";
|
||||
|
||||
const val PLAYMETHOD_DIRECTPLAY = 0
|
||||
const val PLAYMETHOD_DIRECTSTREAM = 1
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
package com.audiobookshelf.app.player
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
|
||||
class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener {
|
||||
var tag = "PlayerNotificationListener"
|
||||
|
||||
companion object {
|
||||
var isForegroundService = false
|
||||
}
|
||||
|
||||
override fun onNotificationPosted(
|
||||
notificationId: Int,
|
||||
notification: Notification,
|
||||
onGoing: Boolean) {
|
||||
|
||||
// Start foreground service
|
||||
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
||||
PlayerNotificationService.isClosed = false
|
||||
playerNotificationService.startForeground(notificationId, notification)
|
||||
if (onGoing && !isForegroundService) {
|
||||
// Start foreground service
|
||||
Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification")
|
||||
PlayerNotificationService.isClosed = false
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
playerNotificationService.startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK)
|
||||
} else {
|
||||
playerNotificationService.startForeground(notificationId, notification)
|
||||
}
|
||||
isForegroundService = true
|
||||
} else {
|
||||
Log.d(tag, "Notification posted $notificationId, not starting foreground - onGoing=$onGoing | isForegroundService=$isForegroundService")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNotificationCancelled(
|
||||
@@ -39,5 +55,6 @@ class PlayerNotificationListener(var playerNotificationService:PlayerNotificatio
|
||||
PlayerNotificationService.isSwitchingPlayer = false
|
||||
}
|
||||
}
|
||||
isForegroundService = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ 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.MediaProgressSyncer
|
||||
import com.audiobookshelf.app.server.ApiHandler
|
||||
import com.google.android.exoplayer2.*
|
||||
import com.google.android.exoplayer2.audio.AudioAttributes
|
||||
@@ -59,6 +60,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
var isStarted = false
|
||||
var isClosed = false
|
||||
var isUnmeteredNetwork = false
|
||||
var hasNetworkConnectivity = false // Not 100% reliable has internet
|
||||
var isSwitchingPlayer = false // Used when switching between cast player and exoplayer
|
||||
}
|
||||
|
||||
@@ -70,7 +72,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
fun onPlayingUpdate(isPlaying: Boolean)
|
||||
fun onMetadata(metadata: PlaybackMetadata)
|
||||
fun onSleepTimerEnded(currentPosition: Long)
|
||||
fun onSleepTimerSet(sleepTimeRemaining: Int)
|
||||
fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean)
|
||||
fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress)
|
||||
fun onPlaybackFailed(errorMessage:String)
|
||||
fun onMediaPlayerChanged(mediaPlayer:String)
|
||||
@@ -78,6 +80,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
fun onProgressSyncSuccess()
|
||||
fun onNetworkMeteredChanged(isUnmetered:Boolean)
|
||||
fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory)
|
||||
fun onPlaybackSpeedChanged(playbackSpeed:Float)
|
||||
}
|
||||
private val binder = LocalBinder()
|
||||
|
||||
@@ -97,7 +100,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
var castPlayer:CastPlayer? = null
|
||||
|
||||
lateinit var sleepTimerManager:SleepTimerManager
|
||||
lateinit var mediaProgressSyncer:MediaProgressSyncer
|
||||
lateinit var mediaProgressSyncer: MediaProgressSyncer
|
||||
|
||||
private var notificationId = 10
|
||||
private var channelId = "audiobookshelf_channel"
|
||||
@@ -172,7 +175,6 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
castPlayer?.release()
|
||||
mediaSession.release()
|
||||
mediaProgressSyncer.reset()
|
||||
Log.d(tag, "onDestroy")
|
||||
isStarted = false
|
||||
|
||||
super.onDestroy()
|
||||
@@ -193,8 +195,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
// To listen for network change from metered to unmetered
|
||||
val networkRequest = NetworkRequest.Builder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_WIFI)
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
.build()
|
||||
val connectivityManager = getSystemService(ConnectivityManager::class.java) as ConnectivityManager
|
||||
connectivityManager.registerNetworkCallback(networkRequest, networkCallback)
|
||||
@@ -378,23 +380,15 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
isClosed = false
|
||||
val customActionProviders = mutableListOf(
|
||||
JumpBackwardCustomActionProvider(),
|
||||
JumpForwardCustomActionProvider(),
|
||||
)
|
||||
|
||||
val metadata = playbackSession.getMediaMetadataCompat(ctx)
|
||||
mediaSession.setMetadata(metadata)
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f
|
||||
initialPlaybackRate = playbackRate
|
||||
|
||||
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
|
||||
customActionProviders.addAll(listOf(
|
||||
SkipBackwardCustomActionProvider(),
|
||||
SkipForwardCustomActionProvider(),
|
||||
))
|
||||
}
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||
// Set actions on Android Auto like jump forward/backward
|
||||
setMediaSessionConnectorCustomActions(playbackSession)
|
||||
|
||||
playbackSession.mediaPlayer = getMediaPlayer()
|
||||
|
||||
@@ -412,6 +406,8 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
currentPlaybackSession = playbackSession
|
||||
DeviceManager.setLastPlaybackSession(playbackSession) // Save playback session to use when app is closed
|
||||
|
||||
Log.d(tag, "Set CurrentPlaybackSession MediaPlayer ${currentPlaybackSession?.mediaPlayer}")
|
||||
|
||||
clientEventEmitter?.onPlaybackSession(playbackSession)
|
||||
@@ -472,6 +468,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun setMediaSessionConnectorCustomActions(playbackSession:PlaybackSession) {
|
||||
val mediaItems = playbackSession.getMediaItems()
|
||||
val customActionProviders = mutableListOf(
|
||||
JumpBackwardCustomActionProvider(),
|
||||
JumpForwardCustomActionProvider(),
|
||||
ChangePlaybackSpeedCustomActionProvider() // Will be pushed to far left
|
||||
)
|
||||
if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) {
|
||||
customActionProviders.addAll(listOf(
|
||||
SkipBackwardCustomActionProvider(),
|
||||
SkipForwardCustomActionProvider(),
|
||||
))
|
||||
}
|
||||
mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray())
|
||||
}
|
||||
|
||||
fun handlePlayerPlaybackError(errorMessage:String) {
|
||||
// On error and was attempting to direct play - fallback to transcode
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
@@ -481,16 +493,19 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
||||
val episodeId = playbackSession.episodeId
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) { // Play request failed
|
||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||
closePlayback(true)
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
mediaProgressSyncer.stop(false) {
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) { // Play request failed
|
||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||
closePlayback(true)
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
clientEventEmitter?.onPlaybackFailed(errorMessage)
|
||||
closePlayback(true)
|
||||
@@ -530,17 +545,22 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
fun startNewPlaybackSession() {
|
||||
currentPlaybackSession?.let { playbackSession ->
|
||||
Log.i(tag, "Starting new playback session for ${playbackSession.displayTitle}")
|
||||
|
||||
val forceTranscode = playbackSession.isHLS // If already HLS then force
|
||||
val playItemRequestPayload = getPlayItemRequestPayload(forceTranscode)
|
||||
|
||||
val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play
|
||||
val episodeId = playbackSession.episodeId
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to start new playback session")
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
mediaProgressSyncer.stop(false) {
|
||||
apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) {
|
||||
if (it == null) {
|
||||
Log.e(tag, "Failed to start new playback session")
|
||||
} else {
|
||||
Log.d(tag, "New playback session response from server with session id ${it.id} for \"${it.displayTitle}\"")
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
preparePlayer(it, true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -662,7 +682,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
if (currentPlaybackSession == null) return true
|
||||
|
||||
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
|
||||
if (!apiHandler.isOnline() || playbackSession.isLocalLibraryItemOnly) {
|
||||
if (!DeviceManager.checkConnectivity(ctx) || playbackSession.isLocalLibraryItemOnly) {
|
||||
return true // carry on
|
||||
}
|
||||
|
||||
@@ -703,7 +723,9 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
|
||||
// Should already be playing
|
||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||
currentPlaybackSession?.let { mediaProgressSyncer.play(it) }
|
||||
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
|
||||
mediaProgressSyncer.play(playbackSession)
|
||||
}
|
||||
clientEventEmitter?.onPlayingUpdate(true)
|
||||
}
|
||||
}
|
||||
@@ -733,7 +755,10 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
currentPlayer.volume = 1F // Volume on sleep timer might have decreased this
|
||||
mediaProgressSyncer.play(it)
|
||||
mediaProgressSyncer.currentPlaybackSession?.let { playbackSession ->
|
||||
mediaProgressSyncer.play(playbackSession)
|
||||
}
|
||||
|
||||
clientEventEmitter?.onPlayingUpdate(true)
|
||||
}
|
||||
}
|
||||
@@ -781,6 +806,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
currentPlaybackSession?.currentTime = timeToSeek / 1000.0
|
||||
val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0
|
||||
val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0
|
||||
Log.d(tag, "seekPlayer seekTo $newWindowIndex | $newTimeOffset")
|
||||
currentPlayer.seekTo(newWindowIndex, newTimeOffset)
|
||||
} else {
|
||||
currentPlayer.seekTo(timeToSeek)
|
||||
@@ -812,7 +838,13 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
}
|
||||
|
||||
fun setPlaybackSpeed(speed: Float) {
|
||||
mediaManager.userSettingsPlaybackRate = speed
|
||||
currentPlayer.setPlaybackSpeed(speed)
|
||||
|
||||
// Refresh Android Auto actions
|
||||
mediaProgressSyncer.currentPlaybackSession?.let {
|
||||
setMediaSessionConnectorCustomActions(it)
|
||||
}
|
||||
}
|
||||
|
||||
fun closePlayback(calledOnError:Boolean? = false) {
|
||||
@@ -849,7 +881,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
return if(currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO
|
||||
}
|
||||
|
||||
fun getDeviceInfo(): DeviceInfo {
|
||||
private fun getDeviceInfo(): DeviceInfo {
|
||||
/* EXAMPLE
|
||||
manufacturer: Google
|
||||
model: Pixel 6
|
||||
@@ -881,7 +913,7 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
//
|
||||
// MEDIA BROWSER STUFF (ANDROID AUTO)
|
||||
//
|
||||
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", "com.audiobookshelf.app.debug", "com.android.systemui", 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 LIBRARIES_ROOT = "__LIBRARIES__"
|
||||
@@ -1086,10 +1118,11 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
networkCapabilities: NetworkCapabilities
|
||||
) {
|
||||
super.onCapabilitiesChanged(network, networkCapabilities)
|
||||
val unmetered = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
Log.i(tag, "Network capabilities changed is unmetered = $unmetered")
|
||||
isUnmeteredNetwork = unmetered
|
||||
clientEventEmitter?.onNetworkMeteredChanged(unmetered)
|
||||
|
||||
isUnmeteredNetwork = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED)
|
||||
hasNetworkConnectivity = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) && networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
Log.i(tag, "Network capabilities changed. hasNetworkConnectivity=$hasNetworkConnectivity | isUnmeteredNetwork=$isUnmeteredNetwork")
|
||||
clientEventEmitter?.onNetworkMeteredChanged(isUnmeteredNetwork)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1160,5 +1193,39 @@ class PlayerNotificationService : MediaBrowserServiceCompat() {
|
||||
).build()
|
||||
}
|
||||
}
|
||||
|
||||
inner class ChangePlaybackSpeedCustomActionProvider : CustomActionProvider {
|
||||
override fun onCustomAction(player: Player, action: String, extras: Bundle?) {
|
||||
/*
|
||||
This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is
|
||||
responsible to reacting to a custom action.
|
||||
*/
|
||||
}
|
||||
|
||||
override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? {
|
||||
val playbackRate = mediaManager.getSavedPlaybackRate()
|
||||
|
||||
// Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app
|
||||
val drawable: Int = when (playbackRate) {
|
||||
in 0.5f..0.7f -> R.drawable.ic_play_speed_0_5x
|
||||
in 0.8f..1.0f -> R.drawable.ic_play_speed_1_0x
|
||||
in 1.1f..1.3f -> R.drawable.ic_play_speed_1_2x
|
||||
in 1.4f..1.7f -> R.drawable.ic_play_speed_1_5x
|
||||
in 1.8f..2.4f -> R.drawable.ic_play_speed_2_0x
|
||||
in 2.5f..3.0f -> R.drawable.ic_play_speed_3_0x
|
||||
// anything set above 3 will be show the 3x to save from creating 100 icons
|
||||
else -> R.drawable.ic_play_speed_3_0x
|
||||
}
|
||||
val customActionExtras = Bundle()
|
||||
customActionExtras.putFloat("speed", playbackRate)
|
||||
return PlaybackStateCompat.CustomAction.Builder(
|
||||
CUSTOM_ACTION_CHANGE_SPEED,
|
||||
getContext().getString(R.string.action_change_speed),
|
||||
drawable
|
||||
)
|
||||
.setExtras(customActionExtras)
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,8 +63,11 @@ class AbsAudioPlayer : Plugin() {
|
||||
emit("onSleepTimerEnded", currentPosition)
|
||||
}
|
||||
|
||||
override fun onSleepTimerSet(sleepTimeRemaining: Int) {
|
||||
emit("onSleepTimerSet", sleepTimeRemaining)
|
||||
override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) {
|
||||
val ret = JSObject()
|
||||
ret.put("value", sleepTimeRemaining)
|
||||
ret.put("isAuto", isAutoSleepTimer)
|
||||
notifyListeners("onSleepTimerSet", ret)
|
||||
}
|
||||
|
||||
override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) {
|
||||
@@ -94,6 +97,10 @@ class AbsAudioPlayer : Plugin() {
|
||||
override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) {
|
||||
notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory)))
|
||||
}
|
||||
|
||||
override fun onPlaybackSpeedChanged(playbackSpeed:Float) {
|
||||
emit("onPlaybackSpeedChanged", playbackSpeed)
|
||||
}
|
||||
})
|
||||
|
||||
MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter
|
||||
@@ -330,7 +337,7 @@ class AbsAudioPlayer : Plugin() {
|
||||
val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||
|
||||
Handler(Looper.getMainLooper()).post {
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
|
||||
val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(time, isChapterTime)
|
||||
val ret = JSObject()
|
||||
ret.put("success", success)
|
||||
call.resolve(ret)
|
||||
|
||||
@@ -216,13 +216,25 @@ class AbsDatabase : Plugin() {
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun syncLocalMediaProgressWithServer(call:PluginCall) {
|
||||
fun syncLocalSessionsWithServer(call:PluginCall) {
|
||||
if (DeviceManager.serverConnectionConfig == null) {
|
||||
Log.e(tag, "syncLocalMediaProgressWithServer not connected to server")
|
||||
Log.e(tag, "syncLocalSessionsWithServer not connected to server")
|
||||
return call.resolve()
|
||||
}
|
||||
apiHandler.syncMediaProgress {
|
||||
call.resolve(JSObject(jacksonMapper.writeValueAsString(it)))
|
||||
|
||||
apiHandler.syncLocalMediaProgressForUser {
|
||||
Log.d(tag, "Finished syncing local media progress for user")
|
||||
val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||
|
||||
if (savedSessions.isNotEmpty()) {
|
||||
apiHandler.sendSyncLocalSessions(savedSessions) { success, errorMsg ->
|
||||
if (!success) {
|
||||
call.resolve(JSObject("{\"error\":\"$errorMsg\"}"))
|
||||
} else {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ class AbsDownloader : Plugin() {
|
||||
folderScanner = FolderScanner(mainActivity)
|
||||
apiHandler = ApiHandler(mainActivity)
|
||||
downloadItemManager = DownloadItemManager(downloadManager, folderScanner, mainActivity, clientEventEmitter)
|
||||
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
package com.audiobookshelf.app.server
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkCapabilities
|
||||
import android.util.Log
|
||||
import com.audiobookshelf.app.data.*
|
||||
import com.audiobookshelf.app.device.DeviceManager
|
||||
import com.audiobookshelf.app.media.MediaEventManager
|
||||
import com.audiobookshelf.app.player.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||
import com.audiobookshelf.app.media.SyncResult
|
||||
import com.audiobookshelf.app.models.User
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||
@@ -28,14 +28,14 @@ class ApiHandler(var ctx:Context) {
|
||||
|
||||
private var defaultClient = OkHttpClient()
|
||||
private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build()
|
||||
var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||
|
||||
data class LocalMediaProgressSyncPayload(val localMediaProgress:List<LocalMediaProgress>)
|
||||
data class LocalSessionsSyncRequestPayload(val sessions:List<PlaybackSession>)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
data class MediaProgressSyncResponsePayload(val numServerProgressUpdates:Int, val localProgressUpdates:List<LocalMediaProgress>, val serverProgressUpdates:List<MediaProgress>)
|
||||
data class LocalMediaProgressSyncResultsPayload(var numLocalMediaProgressForServer:Int, var numServerProgressUpdates:Int, var numLocalProgressUpdates:Int, var serverProgressUpdates:List<MediaProgress>)
|
||||
data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?)
|
||||
data class LocalSessionsSyncResponsePayload(val results:List<LocalSessionSyncResult>)
|
||||
|
||||
fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
private fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
val address = config?.address ?: DeviceManager.serverAddress
|
||||
val token = config?.token ?: DeviceManager.token
|
||||
|
||||
@@ -45,18 +45,20 @@ class ApiHandler(var ctx:Context) {
|
||||
makeRequest(request, httpClient, cb)
|
||||
}
|
||||
|
||||
fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
private fun postRequest(endpoint:String, payload: JSObject, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) {
|
||||
val address = config?.address ?: DeviceManager.serverAddress
|
||||
val token = config?.token ?: DeviceManager.token
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val requestUrl = "${address}$endpoint"
|
||||
Log.d(tag, "postRequest to $requestUrl")
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url("${address}$endpoint").addHeader("Authorization", "Bearer ${token}")
|
||||
.url(requestUrl).addHeader("Authorization", "Bearer ${token}")
|
||||
.build()
|
||||
makeRequest(request, null, cb)
|
||||
}
|
||||
|
||||
fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
||||
private fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) {
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder().patch(requestBody)
|
||||
@@ -65,31 +67,7 @@ class ApiHandler(var ctx:Context) {
|
||||
makeRequest(request, null, cb)
|
||||
}
|
||||
|
||||
fun isOnline(): Boolean {
|
||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
if (capabilities != null) {
|
||||
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI")
|
||||
return true
|
||||
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||
Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET")
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fun isUsingCellularData(): Boolean {
|
||||
val connectivityManager = ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||
return capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true
|
||||
}
|
||||
|
||||
fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
|
||||
private fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) {
|
||||
val client = httpClient ?: defaultClient
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
@@ -135,6 +113,18 @@ class ApiHandler(var ctx:Context) {
|
||||
})
|
||||
}
|
||||
|
||||
fun getCurrentUser(cb: (User?) -> Unit) {
|
||||
getRequest("/api/me", null, null) {
|
||||
if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "getCurrentUser Failed")
|
||||
cb(null)
|
||||
} else {
|
||||
val user = jacksonMapper.readValue<User>(it.toString())
|
||||
cb(user)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getLibraries(cb: (List<Library>) -> Unit) {
|
||||
val mapper = jacksonMapper
|
||||
getRequest("/api/libraries", null,null) {
|
||||
@@ -251,59 +241,6 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
|
||||
fun syncMediaProgress(cb: (LocalMediaProgressSyncResultsPayload) -> Unit) {
|
||||
if (!isOnline()) {
|
||||
Log.d(tag, "Error not online")
|
||||
cb(LocalMediaProgressSyncResultsPayload(0,0,0, mutableListOf()))
|
||||
return
|
||||
}
|
||||
|
||||
// Get all local media progress connected to items on the current connected server
|
||||
val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter {
|
||||
it.serverConnectionConfigId == DeviceManager.serverConnectionConfig?.id
|
||||
}
|
||||
|
||||
val localSyncResultsPayload = LocalMediaProgressSyncResultsPayload(localMediaProgress.size,0, 0, mutableListOf())
|
||||
|
||||
if (localMediaProgress.isNotEmpty()) {
|
||||
Log.d(tag, "Sending sync local progress request with ${localMediaProgress.size} progress items")
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressSyncPayload(localMediaProgress)))
|
||||
postRequest("/api/me/sync-local-progress", payload, null) {
|
||||
Log.d(tag, "Media Progress Sync payload $payload - response ${it}")
|
||||
|
||||
if (it.toString() == "{}") {
|
||||
Log.e(tag, "Progress sync received empty object")
|
||||
} else if (it.has("error")) {
|
||||
Log.e(tag, it.getString("error") ?: "Progress sync error")
|
||||
} else {
|
||||
val progressSyncResponsePayload = jacksonMapper.readValue<MediaProgressSyncResponsePayload>(it.toString())
|
||||
|
||||
localSyncResultsPayload.numLocalProgressUpdates = progressSyncResponsePayload.localProgressUpdates.size
|
||||
localSyncResultsPayload.serverProgressUpdates = progressSyncResponsePayload.serverProgressUpdates
|
||||
localSyncResultsPayload.numServerProgressUpdates = progressSyncResponsePayload.numServerProgressUpdates
|
||||
Log.d(tag, "Media Progress Sync | Local Updates: $localSyncResultsPayload")
|
||||
if (progressSyncResponsePayload.localProgressUpdates.isNotEmpty()) {
|
||||
// Update all local media progress
|
||||
progressSyncResponsePayload.localProgressUpdates.forEach { localMediaProgress ->
|
||||
MediaEventManager.syncEvent(localMediaProgress, "Local progress updated. Received from server sync local API request")
|
||||
|
||||
DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress)
|
||||
}
|
||||
}
|
||||
|
||||
progressSyncResponsePayload.serverProgressUpdates.forEach { localMediaProgress ->
|
||||
MediaEventManager.syncEvent(localMediaProgress, "Server progress updated. Received from server sync local API request")
|
||||
}
|
||||
}
|
||||
|
||||
cb(localSyncResultsPayload)
|
||||
}
|
||||
} else {
|
||||
Log.d(tag, "No local media progress to sync")
|
||||
cb(localSyncResultsPayload)
|
||||
}
|
||||
}
|
||||
|
||||
fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) {
|
||||
Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload")
|
||||
val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId"
|
||||
@@ -375,4 +312,55 @@ class ApiHandler(var ctx:Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendSyncLocalSessions(playbackSessions:List<PlaybackSession>, cb: (Boolean, String?) -> Unit) {
|
||||
val payload = JSObject(jacksonMapper.writeValueAsString(LocalSessionsSyncRequestPayload(playbackSessions)))
|
||||
|
||||
postRequest("/api/session/local-all", payload, null) {
|
||||
if (!it.getString("error").isNullOrEmpty()) {
|
||||
cb(false, it.getString("error"))
|
||||
} else {
|
||||
val response = jacksonMapper.readValue<LocalSessionsSyncResponsePayload>(it.toString())
|
||||
response.results.forEach { localSessionSyncResult ->
|
||||
playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session ->
|
||||
if (localSessionSyncResult.progressSynced == true) {
|
||||
val syncResult = SyncResult(true, true, "Progress synced on server")
|
||||
MediaEventManager.saveEvent(session, syncResult)
|
||||
DeviceManager.dbManager.removePlaybackSession(session.id)
|
||||
Log.i(tag, "Successfully synced session ${session.displayTitle} with server")
|
||||
} else if (!localSessionSyncResult.success) {
|
||||
Log.e(tag, "Failed to sync session ${session.displayTitle} with server. Error: ${localSessionSyncResult.error}")
|
||||
}
|
||||
}
|
||||
}
|
||||
cb(true, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun syncLocalMediaProgressForUser(cb: () -> Unit) {
|
||||
// Get all local media progress for this server
|
||||
val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId }
|
||||
if (allLocalMediaProgress.isEmpty()) {
|
||||
Log.d(tag, "No local media progress to sync")
|
||||
return cb()
|
||||
}
|
||||
|
||||
getCurrentUser { _user ->
|
||||
_user?.let { user->
|
||||
// Compare server user progress with local progress
|
||||
user.mediaProgress.forEach { mediaProgress ->
|
||||
// Get matching local media progress
|
||||
allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress ->
|
||||
if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) {
|
||||
Log.d(tag, "Server progress for media item id=\"${mediaProgress.mediaItemId}\" is more recent then local. Updating local current time ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}")
|
||||
localMediaProgress.updateFromServerMediaProgress(mediaProgress)
|
||||
MediaEventManager.syncEvent(mediaProgress, "Sync on server connection")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
cb()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleY="0.6659007"
|
||||
android:translateY="30.256865">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M7,-50.96875Q7,-69.375,10.6875,-80.84375Q14.390625,-92.328125,21.671875,-98.15625Q28.953125,-104,40,-104Q48.140625,-104,54.53125,-100.625Q60.9375,-97.265625,64.6875,-90.921875Q68.4375,-84.59375,70.71875,-75.5Q73,-66.40625,73,-50.96875Q73,-32.703125,69.34375,-21.21875Q65.6875,-9.75,58.390625,-3.875Q51.109375,2,40,2Q25.359375,2,17.015625,-8.265625Q7,-21.71875,7,-50.96875ZM20,-51Q20,-25.828125,25.765625,-17.90625Q31.53125,-10,40,-10Q48.46875,-10,54.234375,-17.953125Q60,-25.90625,60,-51Q60,-75.734375,54.359375,-83.859375Q48.71875,-92,39.859375,-92Q31.40625,-92,26.09375,-84.828125Q20,-75.671875,20,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
<path android:pathData="M127,-27L140,-28Q141.42188,-19.03125,146.32812,-14.515625Q151.25,-10,159.04688,-10Q168.59375,-10,174.29688,-16.53125Q180,-23.0625,180,-33.859375Q180,-44.140625,174.70312,-50.0625Q169.42188,-56,159.5,-56Q153.29688,-56,148.95312,-53.5Q144.625,-51,142,-47L129,-49L140,-102L188,-102L188,-89L150.54688,-89L145.28125,-61.0625Q153.875,-68,163.3125,-68Q175.8125,-68,184.40625,-58.65625Q193,-49.328125,193,-34.65625Q193,-20.671875,185.125,-10.5Q175.54688,2,158.98438,2Q145.40625,2,136.8125,-5.9375Q128.21875,-13.875,127,-27Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.6"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.0434783"
|
||||
android:scaleY="0.69485295"
|
||||
android:translateX="-11.826087"
|
||||
android:translateY="27.63488">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.0434783"
|
||||
android:scaleY="0.69485295"
|
||||
android:translateX="-11.826087"
|
||||
android:translateY="27.63488">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M192,-13L192,0L124,0Q123.859375,-4.75,125.421875,-9.125Q127.921875,-16.359375,133.4375,-23.25Q138.96875,-30.140625,149.64062,-38.6875Q166.21875,-52.625,172.10938,-60.28125Q178,-67.9375,178,-75.015625Q178,-82.171875,172.70312,-87.078125Q167.40625,-92,158.875,-92Q149.875,-92,144.46875,-86.953125Q139.0625,-81.921875,139,-73L126,-75Q127.328125,-89.15625,135.95312,-96.578125Q144.59375,-104,159.15625,-104Q173.85938,-104,182.42188,-95.71875Q191,-87.4375,191,-75.203125Q191,-68.96875,188.59375,-62.953125Q186.20312,-56.9375,180.64062,-50.28125Q175.09375,-43.640625,161.73438,-32.375Q150.35938,-23.03125,146.96875,-19.6875Q143.59375,-16.359375,141.39062,-13L192,-13Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.0434783"
|
||||
android:scaleY="0.69485295"
|
||||
android:translateX="-11.826087"
|
||||
android:translateY="27.63488">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M54,-0L41,-0L41,-81.03125Q36.484375,-76.65625,29.15625,-72.265625Q21.828125,-67.890625,16,-65.703125L16,-78Q26.546875,-83.015625,34.4375,-90.140625Q42.34375,-97.28125,45.625,-104L54,-104L54,-0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-27L140,-28Q141.42188,-19.03125,146.32812,-14.515625Q151.25,-10,159.04688,-10Q168.59375,-10,174.29688,-16.53125Q180,-23.0625,180,-33.859375Q180,-44.140625,174.70312,-50.0625Q169.42188,-56,159.5,-56Q153.29688,-56,148.95312,-53.5Q144.625,-51,142,-47L129,-49L140,-102L188,-102L188,-89L150.54688,-89L145.28125,-61.0625Q153.875,-68,163.3125,-68Q175.8125,-68,184.40625,-58.65625Q193,-49.328125,193,-34.65625Q193,-20.671875,185.125,-10.5Q175.54688,2,158.98438,2Q145.40625,2,136.8125,-5.9375Q128.21875,-13.875,127,-27Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleY="0.6659007"
|
||||
android:translateY="30.256865">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M72,-13L72,-0L4,-0Q3.859375,-4.75,5.421875,-9.125Q7.921875,-16.359375,13.4375,-23.25Q18.96875,-30.140625,29.640625,-38.6875Q46.21875,-52.625,52.109375,-60.28125Q58,-67.9375,58,-75.015625Q58,-82.171875,52.703125,-87.078125Q47.40625,-92,38.875,-92Q29.875,-92,24.46875,-86.953125Q19.0625,-81.921875,19,-73L6,-75Q7.328125,-89.15625,15.953125,-96.578125Q24.59375,-104,39.15625,-104Q53.859375,-104,62.421875,-95.71875Q71,-87.4375,71,-75.203125Q71,-68.96875,68.59375,-62.953125Q66.203125,-56.9375,60.640625,-50.28125Q55.09375,-43.640625,41.734375,-32.375Q30.359375,-23.03125,26.96875,-19.6875Q23.59375,-16.359375,21.390625,-13L72,-13Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,25 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="272"
|
||||
android:viewportHeight="181.125"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleY="0.6659007"
|
||||
android:translateY="30.256865">
|
||||
<group android:translateY="144.77344">
|
||||
<path android:pathData="M7,-27L20,-29Q22.109375,-18.90625,26.90625,-14.453125Q31.71875,-10,39.484375,-10Q48.609375,-10,54.296875,-16Q60,-22.015625,60,-30.90625Q60,-39.375,54.5,-44.890625Q49,-50.40625,40.515625,-50.40625Q37.046875,-50.40625,31.890625,-49L33.25,-61Q34.46875,-60.859375,35.203125,-60.859375Q42.8125,-60.859375,48.90625,-64.796875Q55,-68.75,55,-76.953125Q55,-83.46875,50.484375,-87.734375Q45.984375,-92,38.84375,-92Q31.765625,-92,27.046875,-87.75Q22.34375,-83.5,21,-75L8,-77Q10.28125,-89.859375,18.34375,-96.921875Q26.40625,-104,38.40625,-104Q46.6875,-104,53.65625,-100.390625Q60.625,-96.796875,64.3125,-90.578125Q68,-84.359375,68,-77.359375Q68,-70.71875,64.40625,-65.265625Q60.828125,-59.8125,53.796875,-56.59375Q62.9375,-54.453125,67.96875,-47.6875Q73,-40.9375,73,-30.796875Q73,-17.078125,63.375,-7.53125Q53.75,2,39.03125,2Q25.765625,2,17,-6.078125Q8.234375,-14.15625,7,-27Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M93,0L93,-13L106,-13L106,0L93,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M127,-50.96875Q127,-69.375,130.6875,-80.84375Q134.39062,-92.328125,141.67188,-98.15625Q148.95312,-104,160,-104Q168.14062,-104,174.53125,-100.625Q180.9375,-97.265625,184.6875,-90.921875Q188.4375,-84.59375,190.71875,-75.5Q193,-66.40625,193,-50.96875Q193,-32.703125,189.34375,-21.21875Q185.6875,-9.75,178.39062,-3.875Q171.10938,2,160,2Q145.35938,2,137.01562,-8.265625Q127,-21.71875,127,-50.96875ZM140,-51Q140,-25.828125,145.76562,-17.90625Q151.53125,-10,160,-10Q168.46875,-10,174.23438,-17.953125Q180,-25.90625,180,-51Q180,-75.734375,174.35938,-83.859375Q168.71875,-92,159.85938,-92Q151.40625,-92,146.09375,-84.828125Q140,-75.671875,140,-51Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
<path android:pathData="M201,0L228.85938,-38L203,-75L218.57812,-75L230.82812,-57.484375Q234.23438,-52.609375,236.84375,-48.890625Q239.65625,-52.71875,242.28125,-56.296875L256,-75L271.875,-75L244.59375,-37.796875L271,0L255.42188,0L239.48438,-22.796875L236.60938,-26.90625L216.875,0L201,0Z"
|
||||
android:fillColor="#000000"
|
||||
android:fillAlpha="0.8"/>
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 500 B |
|
After Width: | Height: | Size: 447 B |
|
After Width: | Height: | Size: 450 B |
|
After Width: | Height: | Size: 465 B |
|
After Width: | Height: | Size: 512 B |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 323 B |
|
After Width: | Height: | Size: 311 B |
|
After Width: | Height: | Size: 303 B |
|
After Width: | Height: | Size: 315 B |
|
After Width: | Height: | Size: 338 B |
|
After Width: | Height: | Size: 346 B |
|
After Width: | Height: | Size: 664 B |
|
After Width: | Height: | Size: 608 B |
|
After Width: | Height: | Size: 583 B |
|
After Width: | Height: | Size: 599 B |
|
After Width: | Height: | Size: 684 B |
|
After Width: | Height: | Size: 712 B |
|
After Width: | Height: | Size: 1013 B |
|
After Width: | Height: | Size: 895 B |
|
After Width: | Height: | Size: 866 B |
|
After Width: | Height: | Size: 892 B |
|
After Width: | Height: | Size: 1011 B |
|
After Width: | Height: | Size: 1.0 KiB |
@@ -10,4 +10,5 @@
|
||||
<string name="action_jump_backward">Jump Backward</string>
|
||||
<string name="action_skip_forward">Skip Forward</string>
|
||||
<string name="action_skip_backward">Skip Backward</string>
|
||||
<string name="action_change_speed">Change Playback Speed</string>
|
||||
</resources>
|
||||
|
||||
@@ -14,7 +14,7 @@ ext {
|
||||
cordovaAndroidVersion = '10.1.1'
|
||||
androidx_car_version = '1.0.0-alpha7'
|
||||
androidx_core_ktx_version = '1.7.0'
|
||||
androidx_media_version = '1.5.0'
|
||||
androidx_media_version = '1.6.0'
|
||||
androidx_preference_version = '1.1.1'
|
||||
androidx_test_runner_version = '1.3.0'
|
||||
arch_lifecycle_version = '2.2.0'
|
||||
|
||||
@@ -20,9 +20,9 @@ body {
|
||||
}
|
||||
|
||||
#content.playerOpen {
|
||||
height: calc(100% - 164px);
|
||||
min-height: calc(100% - 164px);
|
||||
max-height: calc(100% - 164px);
|
||||
height: calc(100% - 184px);
|
||||
min-height: calc(100% - 184px);
|
||||
max-height: calc(100% - 184px);
|
||||
}
|
||||
|
||||
#bookshelf {
|
||||
|
||||
@@ -48,25 +48,6 @@
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
/* latin */
|
||||
@font-face {
|
||||
font-family: 'Gentium Book Basic';
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url(/fonts/GentiumBookBasic.woff2) format('woff2');
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
|
||||
/* cyrillic-ext */
|
||||
@font-face {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div v-if="user && currentLibrary">
|
||||
<div class="pl-1.5 pr-2.5 py-2 bg-bg bg-opacity-30 rounded-md flex items-center" @click="clickShowLibraryModal">
|
||||
<ui-library-icon :icon="currentLibraryIcon" :size="4" font-size="base" />
|
||||
<p class="text-sm font-book leading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
<p class="text-smleading-4 ml-2 mt-0.5 max-w-24 truncate">{{ currentLibraryName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -143,6 +143,7 @@ export default {
|
||||
onMetadataListener: null,
|
||||
onProgressSyncFailing: null,
|
||||
onProgressSyncSuccess: null,
|
||||
onPlaybackSpeedChangedListener: null,
|
||||
touchStartY: 0,
|
||||
touchStartTime: 0,
|
||||
touchEndY: 0,
|
||||
@@ -317,7 +318,7 @@ export default {
|
||||
return this.$secondsToTimestamp(this.totalDuration)
|
||||
},
|
||||
currentTimePretty() {
|
||||
return this.$secondsToTimestamp(this.currentTime)
|
||||
return this.$secondsToTimestamp(this.currentTime / this.currentPlaybackRate)
|
||||
},
|
||||
timeRemaining() {
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
@@ -452,6 +453,7 @@ export default {
|
||||
await this.$hapticsImpact()
|
||||
console.log(`[AudioPlayer] Set Playback Rate: ${speed}`)
|
||||
this.currentPlaybackRate = speed
|
||||
this.updateTimestamp()
|
||||
AbsAudioPlayer.setPlaybackSpeed({ value: speed })
|
||||
},
|
||||
restart() {
|
||||
@@ -502,19 +504,17 @@ export default {
|
||||
}
|
||||
},
|
||||
updateTimestamp() {
|
||||
var ts = this.$refs.currentTimestamp
|
||||
const ts = this.$refs.currentTimestamp
|
||||
if (!ts) {
|
||||
console.error('No timestamp el')
|
||||
return
|
||||
}
|
||||
var currTimeStr = ''
|
||||
let currentTime = this.currentTime / this.currentPlaybackRate
|
||||
if (this.useChapterTrack && this.currentChapter) {
|
||||
var currChapTime = Math.max(0, this.currentTime - this.currentChapter.start)
|
||||
currTimeStr = this.$secondsToTimestamp(currChapTime)
|
||||
} else {
|
||||
currTimeStr = this.$secondsToTimestamp(this.currentTime)
|
||||
const currChapTime = Math.max(0, this.currentTime - this.currentChapter.start)
|
||||
currentTime = currChapTime / this.currentPlaybackRate
|
||||
}
|
||||
ts.innerText = currTimeStr
|
||||
ts.innerText = this.$secondsToTimestamp(currentTime)
|
||||
},
|
||||
timeupdate() {
|
||||
if (!this.$refs.playedTrack) {
|
||||
@@ -783,8 +783,6 @@ export default {
|
||||
|
||||
console.log('received metadata update', data)
|
||||
|
||||
if (data.currentRate && data.currentRate > 0) this.playbackSpeed = data.currentRate
|
||||
|
||||
this.timeupdate()
|
||||
},
|
||||
// When a playback session is started the native android/ios will send the session
|
||||
@@ -815,6 +813,11 @@ export default {
|
||||
this.$toast.error(`Playback Failed: ${errorMessage}`)
|
||||
this.endPlayback()
|
||||
},
|
||||
onPlaybackSpeedChanged(data) {
|
||||
if (!data.value || isNaN(data.value)) return
|
||||
this.currentPlaybackRate = Number(data.value)
|
||||
this.updateTimestamp()
|
||||
},
|
||||
async init() {
|
||||
this.useChapterTrack = await this.$localStore.getUseChapterTrack()
|
||||
this.lockUi = await this.$localStore.getPlayerLock()
|
||||
@@ -826,6 +829,7 @@ export default {
|
||||
this.onMetadataListener = AbsAudioPlayer.addListener('onMetadata', this.onMetadata)
|
||||
this.onProgressSyncFailing = AbsAudioPlayer.addListener('onProgressSyncFailing', this.showProgressSyncIsFailing)
|
||||
this.onProgressSyncSuccess = AbsAudioPlayer.addListener('onProgressSyncSuccess', this.showProgressSyncSuccess)
|
||||
this.onPlaybackSpeedChangedListener = AbsAudioPlayer.addListener('onPlaybackSpeedChanged', this.onPlaybackSpeedChanged)
|
||||
},
|
||||
screenOrientationChange() {
|
||||
setTimeout(() => {
|
||||
@@ -849,7 +853,7 @@ export default {
|
||||
document.documentElement.style.setProperty('--title-author-left-offset-collapsed', 24 + coverImageWidthCollapsed + 'px')
|
||||
},
|
||||
minimizePlayerEvt() {
|
||||
this.showFullscreen = false
|
||||
this.collapseFullscreen()
|
||||
},
|
||||
showProgressSyncIsFailing() {
|
||||
this.syncStatus = this.$constants.SyncStatus.FAILED
|
||||
@@ -901,6 +905,7 @@ export default {
|
||||
if (this.onPlaybackFailedListener) this.onPlaybackFailedListener.remove()
|
||||
if (this.onProgressSyncFailing) this.onProgressSyncFailing.remove()
|
||||
if (this.onProgressSyncSuccess) this.onProgressSyncSuccess.remove()
|
||||
if (this.onPlaybackSpeedChangedListener) this.onPlaybackSpeedChangedListener.remove()
|
||||
clearInterval(this.playInterval)
|
||||
}
|
||||
}
|
||||
@@ -916,7 +921,7 @@ export default {
|
||||
}
|
||||
|
||||
.playerContainer {
|
||||
height: 100px;
|
||||
height: 120px;
|
||||
}
|
||||
.fullscreen .playerContainer {
|
||||
height: 200px;
|
||||
@@ -931,15 +936,14 @@ export default {
|
||||
#playerTrack {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
transition-property: margin;
|
||||
bottom: 20px;
|
||||
bottom: 35px;
|
||||
}
|
||||
.fullscreen #playerTrack {
|
||||
top: 20px;
|
||||
bottom: unset;
|
||||
}
|
||||
|
||||
.cover-wrapper {
|
||||
bottom: 48px;
|
||||
bottom: 68px;
|
||||
left: 12px;
|
||||
height: var(--cover-image-height-collapsed);
|
||||
width: var(--cover-image-width-collapsed);
|
||||
@@ -960,7 +964,7 @@ export default {
|
||||
transform-origin: left bottom;
|
||||
|
||||
width: 40%;
|
||||
bottom: 56px;
|
||||
bottom: 76px;
|
||||
left: var(--title-author-left-offset-collapsed);
|
||||
text-align: left;
|
||||
}
|
||||
@@ -999,7 +1003,7 @@ export default {
|
||||
width: 140px;
|
||||
padding-left: 12px;
|
||||
padding-right: 12px;
|
||||
bottom: 50px;
|
||||
bottom: 70px;
|
||||
}
|
||||
#playerControls .jump-icon {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
@@ -1017,7 +1021,6 @@ export default {
|
||||
min-width: 40px;
|
||||
min-height: 40px;
|
||||
margin: 0px 14px;
|
||||
/* padding: 8px; */
|
||||
}
|
||||
#playerControls .play-btn .material-icons {
|
||||
transition: all 0.15s cubic-bezier(0.39, 0.575, 0.565, 1);
|
||||
@@ -1047,7 +1050,6 @@ export default {
|
||||
font-size: 2rem;
|
||||
}
|
||||
.fullscreen #playerControls .play-btn {
|
||||
/* padding: 16px; */
|
||||
height: 65px;
|
||||
width: 65px;
|
||||
min-width: 65px;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<app-audio-player ref="audioPlayer" :bookmarks="bookmarks" :sleep-timer-running="isSleepTimerRunning" :sleep-time-remaining="sleepTimeRemaining" :is-server-item="!!serverLibraryItemId" @selectPlaybackSpeed="showPlaybackSpeedModal = true" @updateTime="(t) => (currentTime = t)" @showSleepTimer="showSleepTimer" @showBookmarks="showBookmarks" />
|
||||
|
||||
<modals-playback-speed-modal v-model="showPlaybackSpeedModal" :playback-rate.sync="playbackSpeed" @update:playbackRate="updatePlaybackSpeed" @change="changePlaybackSpeed" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
||||
<modals-sleep-timer-modal v-model="showSleepTimerModal" :current-time="sleepTimeRemaining" :sleep-timer-running="isSleepTimerRunning" :current-end-of-chapter-time="currentEndOfChapterTime" :is-auto="isAutoSleepTimer" @change="selectSleepTimeout" @cancel="cancelSleepTimer" @increase="increaseSleepTimer" @decrease="decreaseSleepTimer" />
|
||||
<modals-bookmarks-modal v-model="showBookmarksModal" :bookmarks="bookmarks" :current-time="currentTime" :library-item-id="serverLibraryItemId" @select="selectBookmark" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -28,6 +28,7 @@ export default {
|
||||
isSleepTimerRunning: false,
|
||||
sleepTimerEndTime: 0,
|
||||
sleepTimeRemaining: 0,
|
||||
isAutoSleepTimer: false,
|
||||
onLocalMediaProgressUpdateListener: null,
|
||||
onSleepTimerEndedListener: null,
|
||||
onSleepTimerSetListener: null,
|
||||
@@ -42,6 +43,9 @@ export default {
|
||||
bookmarks() {
|
||||
if (!this.serverLibraryItemId) return []
|
||||
return this.$store.getters['user/getUserBookmarksForItem'](this.serverLibraryItemId)
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -62,8 +66,9 @@ export default {
|
||||
console.log('Sleep Timer Ended Current Position: ' + currentPosition)
|
||||
}
|
||||
},
|
||||
onSleepTimerSet({ value: sleepTimeRemaining }) {
|
||||
console.log('SLEEP TIMER SET', sleepTimeRemaining)
|
||||
onSleepTimerSet(payload) {
|
||||
const { value: sleepTimeRemaining, isAuto } = payload
|
||||
console.log('SLEEP TIMER SET', JSON.stringify(payload))
|
||||
if (sleepTimeRemaining === 0) {
|
||||
console.log('Sleep timer canceled')
|
||||
this.isSleepTimerRunning = false
|
||||
@@ -71,6 +76,7 @@ export default {
|
||||
this.isSleepTimerRunning = true
|
||||
}
|
||||
|
||||
this.isAutoSleepTimer = !!isAuto
|
||||
this.sleepTimeRemaining = sleepTimeRemaining
|
||||
},
|
||||
showSleepTimer() {
|
||||
@@ -185,6 +191,7 @@ export default {
|
||||
const libraryItemId = payload.libraryItemId
|
||||
const episodeId = payload.episodeId
|
||||
const startTime = payload.startTime
|
||||
const startWhenReady = !payload.paused
|
||||
|
||||
// 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
|
||||
@@ -220,7 +227,7 @@ export default {
|
||||
}
|
||||
|
||||
console.log('Called playLibraryItem', libraryItemId)
|
||||
const preparePayload = { libraryItemId, episodeId, playWhenReady: true, playbackRate }
|
||||
const preparePayload = { libraryItemId, episodeId, playWhenReady: startWhenReady, playbackRate }
|
||||
if (startTime !== undefined && startTime !== null) preparePayload.startTime = startTime
|
||||
AbsAudioPlayer.prepareLibraryItem(preparePayload)
|
||||
.then((data) => {
|
||||
@@ -265,9 +272,13 @@ export default {
|
||||
this.notifyOnReady()
|
||||
},
|
||||
notifyOnReady() {
|
||||
// TODO: iOS opens last active playback session on app launch. Should be consistent with Android
|
||||
if (!this.isIos) return
|
||||
|
||||
// If settings aren't loaded yet, native player will receive incorrect settings
|
||||
console.log('Notify on ready... settingsLoaded:', this.settingsLoaded, 'isReady:', this.isReady)
|
||||
if (this.settingsLoaded && this.isReady) {
|
||||
if (this.settingsLoaded && this.isReady && this.$store.state.isFirstAudioLoad) {
|
||||
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
|
||||
AbsAudioPlayer.onReady()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="!altViewEnabled" class="absolute text-center categoryPlacard font-book transform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 18px">
|
||||
<div v-if="!altViewEnabled" class="absolute text-center categoryPlacardtransform z-30 bottom-0.5 left-4 md:left-8 w-36 rounded-md" style="height: 18px">
|
||||
<div class="w-full h-full flex items-center justify-center rounded-sm border shinyBlack">
|
||||
<p class="transform text-xs">{{ label }}</p>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
<div class="w-full h-full absolute top-0 left-0 rounded overflow-hidden z-10">
|
||||
<div v-show="libraryItem && !imageReady" class="absolute top-0 left-0 w-full h-full flex items-center justify-center" :style="{ padding: sizeMultiplier * 0.5 + 'rem' }">
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="font-book text-gray-300 text-center">{{ title }}</p>
|
||||
<p :style="{ fontSize: sizeMultiplier * 0.8 + 'rem' }" class="text-gray-300 text-center">{{ title }}</p>
|
||||
</div>
|
||||
|
||||
<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 }" />
|
||||
@@ -26,11 +26,11 @@
|
||||
<!-- Placeholder Cover Title & Author -->
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
<p class="text-center" style="color: rgb(247 223 187)" :style="{ fontSize: titleFontSize + 'rem' }">{{ titleCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p class="text-center font-book" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
<p class="text-center" style="color: rgb(247 223 187); opacity: 0.75" :style="{ fontSize: authorFontSize + 'rem' }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<covers-collection-cover ref="cover" :book-items="books" :width="width" :height="height" :book-cover-aspect-ratio="bookCoverAspectRatio" />
|
||||
</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(240, width) + 'px' }">
|
||||
<div class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(240, 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>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<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="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-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>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div v-if="isAltViewEnabled && isCategorized" class="absolute z-30 left-0 right-0 mx-auto -bottom-8 h-8 py-1 rounded-md text-center">
|
||||
<p class="truncate" :style="{ fontSize: labelFontSize + 'rem' }">{{ title }}</p>
|
||||
</div>
|
||||
<div v-if="!isCategorized" 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(240, width) + 'px' }">
|
||||
<div v-if="!isCategorized" class="categoryPlacard absolute z-30 left-0 right-0 mx-auto -bottom-6 h-6 rounded-mdtext-center" :style="{ width: Math.min(240, 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>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
<template>
|
||||
<div class="relative rounded-sm overflow-hidden" :style="{ height: height + 'px', width: width + 'px', maxWidth: width + 'px', minWidth: width + 'px' }">
|
||||
<div class="w-full h-full relative bg-bg">
|
||||
<div class="w-full h-full relative" :class="{ 'bg-bg': !noBg }">
|
||||
<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 v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? 1 : 0 }" :class="showCoverBg && hasCover ? 'object-contain' : 'object-fill'" />
|
||||
<img v-if="fullCoverUrl" ref="cover" :src="fullCoverUrl" loading="lazy" @error="imageError" @load="imageLoaded" class="w-full h-full absolute top-0 left-0 z-10 duration-300 transition-opacity" :style="{ opacity: imageReady ? 1 : 0 }" :class="(showCoverBg && hasCover) || noBg ? 'object-contain' : 'object-fill'" />
|
||||
|
||||
<div v-show="loading && libraryItem" class="absolute top-0 left-0 h-full w-full flex items-center justify-center">
|
||||
<p class="font-book text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<p class="text-center" :style="{ fontSize: 0.75 * sizeMultiplier + 'rem' }">{{ title }}</p>
|
||||
<div class="absolute top-2 right-2">
|
||||
<widgets-loading-spinner />
|
||||
</div>
|
||||
@@ -18,17 +18,17 @@
|
||||
<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" loading="lazy" class="mb-2" :style="{ height: 64 * sizeMultiplier + 'px' }" />
|
||||
<p class="text-center font-book text-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
<p class="text-centertext-error" :style="{ fontSize: titleFontSize + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!hasCover" class="absolute top-0 left-0 right-0 bottom-0 w-full h-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem' }">
|
||||
<div>
|
||||
<p class="text-center font-book truncate leading-none origin-center" style="color: rgb(247 223 187); font-size: 0.8rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ titleCleaned }}</p>
|
||||
<p class="text-centertruncate leading-none origin-center" style="color: rgb(247 223 187); font-size: 0.8rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ titleCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!hasCover" class="absolute left-0 right-0 w-full flex items-center justify-center z-10" :style="{ padding: placeholderCoverPadding + 'rem', bottom: authorBottom + 'rem' }">
|
||||
<p class="text-center font-book truncate leading-none origin-center" style="color: rgb(247 223 187); opacity: 0.75; font-size: 0.6rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ authorCleaned }}</p>
|
||||
<p class="text-centertruncate leading-none origin-center" style="color: rgb(247 223 187); opacity: 0.75; font-size: 0.6rem" :style="{ transform: `scale(${sizeMultiplier})` }">{{ authorCleaned }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -48,7 +48,8 @@ export default {
|
||||
},
|
||||
bookCoverAspectRatio: Number,
|
||||
downloadCover: String,
|
||||
raw: Boolean
|
||||
raw: Boolean,
|
||||
noBg: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -156,7 +157,7 @@ export default {
|
||||
this.$nextTick(() => {
|
||||
this.imageReady = true
|
||||
})
|
||||
if (this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||
if (!this.noBg && this.$refs.cover && this.cover !== this.placeholderUrl) {
|
||||
var { naturalWidth, naturalHeight } = this.$refs.cover
|
||||
var aspectRatio = naturalHeight / naturalWidth
|
||||
var arDiff = Math.abs(aspectRatio - this.bookCoverAspectRatio)
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<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" />
|
||||
|
||||
<p class="font-book text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
<p class="text-white text-opacity-60 text-center" :style="{ fontSize: Math.min(1, sizeMultiplier) + 'rem' }">Empty Collection</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -134,7 +134,7 @@ export default {
|
||||
|
||||
var innerP = document.createElement('p')
|
||||
innerP.textContent = this.name
|
||||
innerP.className = 'text-sm font-book text-white'
|
||||
innerP.className = 'text-smtext-white'
|
||||
imgdiv.appendChild(innerP)
|
||||
|
||||
return imgdiv
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
<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>
|
||||
<p class="text-centertext-error" :style="{ fontSize: sizeMultiplier + 'rem' }">Invalid Cover</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
<nuxt-link to="/bookshelf/series" v-if="selectedSeriesName" class="pt-1">
|
||||
<span class="material-icons">arrow_back</span>
|
||||
</nuxt-link>
|
||||
<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="pt-1">{{ totalEntities }} {{ entityTitle }}</p>
|
||||
<p v-show="selectedSeriesName" class="ml-2pt-1">{{ selectedSeriesName }} ({{ totalEntities }})</p>
|
||||
<div class="flex-grow" />
|
||||
<span v-if="page == 'library' || seriesBookPage" class="material-icons px-2" @click="changeView">{{ !bookshelfListView ? 'view_list' : 'grid_view' }}</span>
|
||||
<template v-if="page === 'library'">
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</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 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 p-2" style="max-height: 75%" @click.stop>
|
||||
<ul class="h-full w-full" role="listbox" aria-labelledby="listbox-label">
|
||||
<template v-for="item in items">
|
||||
<slot :name="item.value" :item="item" :selected="item.value === selected">
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<ui-btn @click="increaseSleepTime" 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="cancelSleepTimer" class="w-full">Cancel Timer</ui-btn>
|
||||
<ui-btn @click="cancelSleepTimer" class="w-full">{{ isAuto ? 'Disable Auto Timer' : 'Cancel Timer' }}</ui-btn>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,12 +59,15 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { Dialog } from '@capacitor/dialog'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: Boolean,
|
||||
currentTime: Number,
|
||||
sleepTimerRunning: Boolean,
|
||||
currentEndOfChapterTime: Number
|
||||
currentEndOfChapterTime: Number,
|
||||
isAuto: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -102,6 +105,14 @@ export default {
|
||||
this.$nextTick(() => this.$emit('change', { time: timeout, isChapterTime: false }))
|
||||
},
|
||||
async cancelSleepTimer() {
|
||||
if (this.isAuto) {
|
||||
const { value } = await Dialog.confirm({
|
||||
title: 'Confirm',
|
||||
message: 'Are you sure you want to disable the auto sleep timer? You will need to enable this again in settings.'
|
||||
})
|
||||
if (!value) return
|
||||
}
|
||||
|
||||
await this.$hapticsImpact()
|
||||
this.$emit('cancel')
|
||||
this.show = false
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div id="epub-frame" class="w-full">
|
||||
<div id="viewer" class="border border-gray-100 bg-white shadow-md h-full w-full"></div>
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-2 flex items-center" :style="{ bottom: playerLibraryItemId ? '100px' : '0px' }">
|
||||
<div class="fixed left-0 h-8 w-full bg-bg px-2 flex items-center" :style="{ bottom: playerLibraryItemId ? '120px' : '0px' }">
|
||||
<p class="text-xs">epub</p>
|
||||
<div class="flex-grow" />
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div class="w-96 my-6 mx-auto">
|
||||
<h1 class="text-2xl mb-4 font-book">Minutes Listening <span class="text-white text-opacity-60 text-lg">(Last 7 days)</span></h1>
|
||||
<h1 class="text-2xl mb-4">Minutes Listening <span class="text-white text-opacity-60 text-lg">(Last 7 days)</span></h1>
|
||||
<div class="relative w-96 h-72">
|
||||
<div class="absolute top-0 left-0">
|
||||
<template v-for="lbl in yAxisLabels">
|
||||
@@ -25,7 +25,7 @@
|
||||
<div class="absolute -bottom-2 left-0 flex ml-6">
|
||||
<template v-for="dayObj in last7Days">
|
||||
<div :key="dayObj.date" :style="{ width: daySpacing + daySpacing / 14 + 'px' }">
|
||||
<p class="text-sm font-book">{{ dayObj.dayOfWeek.slice(0, 3) }}</p>
|
||||
<p class="text-sm">{{ dayObj.dayOfWeek.slice(0, 3) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -44,13 +44,12 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
clickedIt() {
|
||||
if (this.isIos) return // TODO: Implement on iOS
|
||||
this.$router.push('/downloading')
|
||||
},
|
||||
onItemDownloadComplete(data) {
|
||||
console.log('DownloadProgressIndicator onItemDownloadComplete', JSON.stringify(data))
|
||||
if (!data || !data.libraryItemId) {
|
||||
console.error('Invalid item downlaod complete payload')
|
||||
console.error('Invalid item download complete payload')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
504EC30F1FED79650016851F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 504EC30E1FED79650016851F /* Assets.xcassets */; };
|
||||
504EC3121FED79650016851F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 504EC3101FED79650016851F /* LaunchScreen.storyboard */; };
|
||||
50B271D11FEDC1A000F3C39B /* public in Resources */ = {isa = PBXBuildFile; fileRef = 50B271D01FEDC1A000F3C39B /* public */; };
|
||||
AC98F95F300E46A27FAE47A4 /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B775656E49492CFD6763B7B6 /* Pods_Audiobookshelf.framework */; };
|
||||
65643F4DAA661FB0B247247E /* Pods_Audiobookshelf.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */; };
|
||||
E9D5504628AC1A3900C746DD /* LibraryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504528AC1A3900C746DD /* LibraryItem.swift */; };
|
||||
E9D5504828AC1A7A00C746DD /* MediaType.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504728AC1A7A00C746DD /* MediaType.swift */; };
|
||||
E9D5504A28AC1AA600C746DD /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E9D5504928AC1AA600C746DD /* Metadata.swift */; };
|
||||
@@ -106,10 +106,9 @@
|
||||
504EC3111FED79650016851F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||
504EC3131FED79650016851F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
50B271D01FEDC1A000F3C39B /* public */ = {isa = PBXFileReference; lastKnownFileType = folder; path = public; sourceTree = "<group>"; };
|
||||
5F7EC3E03050241EA35154B3 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Pods/Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; };
|
||||
8FBD5FE4076DDFA0DA782256 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.release.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.release.xcconfig"; sourceTree = "<group>"; };
|
||||
B775656E49492CFD6763B7B6 /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Audiobookshelf.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.debug.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Audiobookshelf.release.xcconfig"; path = "Target Support Files/Pods-Audiobookshelf/Pods-Audiobookshelf.release.xcconfig"; sourceTree = "<group>"; };
|
||||
E9D5504528AC1A3900C746DD /* LibraryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryItem.swift; sourceTree = "<group>"; };
|
||||
E9D5504728AC1A7A00C746DD /* MediaType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MediaType.swift; sourceTree = "<group>"; };
|
||||
E9D5504928AC1AA600C746DD /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
|
||||
@@ -139,7 +138,6 @@
|
||||
E9E8814C28DA6B9000D750C1 /* PlayerTimeUtilsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerTimeUtilsTests.swift; sourceTree = "<group>"; };
|
||||
E9E985F728B02D9400957F23 /* PlayerProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerProgress.swift; sourceTree = "<group>"; };
|
||||
E9FA07E228C82848005520B0 /* Logger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App.debug.xcconfig"; path = "Pods/Target Support Files/Pods-App/Pods-App.debug.xcconfig"; sourceTree = "<group>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -147,7 +145,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AC98F95F300E46A27FAE47A4 /* Pods_Audiobookshelf.framework in Frameworks */,
|
||||
65643F4DAA661FB0B247247E /* Pods_Audiobookshelf.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -161,14 +159,24 @@
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */ = {
|
||||
2372704BEF15C94CF4EBDB08 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B775656E49492CFD6763B7B6 /* Pods_Audiobookshelf.framework */,
|
||||
8F9A7CCC479333E44DC314BE /* Pods_Audiobookshelf.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
39E576BF5B2DD22C4195A799 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */,
|
||||
D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
3ABF6190280432610070250E /* player */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -245,8 +253,8 @@
|
||||
504EC3061FED79650016851F /* App */,
|
||||
E9E8813B28DA5DE500D750C1 /* AudiobookshelfUnitTests */,
|
||||
504EC3051FED79650016851F /* Products */,
|
||||
7F8756D8B27F46E3366F6CEA /* Pods */,
|
||||
27E2DDA53C4D2A4D1A88CE4A /* Frameworks */,
|
||||
39E576BF5B2DD22C4195A799 /* Pods */,
|
||||
2372704BEF15C94CF4EBDB08 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -276,17 +284,6 @@
|
||||
path = App;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
7F8756D8B27F46E3366F6CEA /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
FC68EB0AF532CFC21C3344DD /* Pods-App.debug.xcconfig */,
|
||||
AF51FD2D460BCFE21FA515B2 /* Pods-App.release.xcconfig */,
|
||||
8FBD5FE4076DDFA0DA782256 /* Pods-Audiobookshelf.debug.xcconfig */,
|
||||
5F7EC3E03050241EA35154B3 /* Pods-Audiobookshelf.release.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
E9D5506328AC1D3F00C746DD /* server */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -374,11 +371,11 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 504EC3161FED79650016851F /* Build configuration list for PBXNativeTarget "Audiobookshelf" */;
|
||||
buildPhases = (
|
||||
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */,
|
||||
C5582D6256D39377D737E21E /* [CP] Check Pods Manifest.lock */,
|
||||
504EC3001FED79650016851F /* Sources */,
|
||||
504EC3011FED79650016851F /* Frameworks */,
|
||||
504EC3021FED79650016851F /* Resources */,
|
||||
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */,
|
||||
CEB30CA5B985BAFB7ABA9E2F /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -471,16 +468,20 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
6634F4EFEBD30273BCE97C65 /* [CP] Check Pods Manifest.lock */ = {
|
||||
C5582D6256D39377D737E21E /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Audiobookshelf-checkManifestLockResult.txt",
|
||||
);
|
||||
@@ -489,7 +490,7 @@
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9592DBEFFC6D2A0C8D5DEB22 /* [CP] Embed Pods Frameworks */ = {
|
||||
CEB30CA5B985BAFB7ABA9E2F /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
@@ -715,17 +716,17 @@
|
||||
};
|
||||
504EC3171FED79650016851F /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 8FBD5FE4076DDFA0DA782256 /* Pods-Audiobookshelf.debug.xcconfig */;
|
||||
baseConfigurationReference = BE96D57E131924D520D57057 /* Pods-Audiobookshelf.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.61;
|
||||
MARKETING_VERSION = 0.9.62;
|
||||
OTHER_SWIFT_FLAGS = "$(inherited) \"-D\" \"COCOAPODS\" \"-DDEBUG\"";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app.dev;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
@@ -739,17 +740,17 @@
|
||||
};
|
||||
504EC3181FED79650016851F /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 5F7EC3E03050241EA35154B3 /* Pods-Audiobookshelf.release.xcconfig */;
|
||||
baseConfigurationReference = D2F7F575384A63F1C47DE984 /* Pods-Audiobookshelf.release.xcconfig */;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = Icons;
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 18;
|
||||
CURRENT_PROJECT_VERSION = 19;
|
||||
DEVELOPMENT_TEAM = 7UFJ7D8V6A;
|
||||
INFOPLIST_FILE = App/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||
MARKETING_VERSION = 0.9.61;
|
||||
MARKETING_VERSION = 0.9.62;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.audiobookshelf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "";
|
||||
|
||||
@@ -14,7 +14,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
// Override point for customization after application launch.
|
||||
|
||||
let configuration = Realm.Configuration(
|
||||
schemaVersion: 6,
|
||||
schemaVersion: 7,
|
||||
migrationBlock: { [weak self] migration, oldSchemaVersion in
|
||||
if (oldSchemaVersion < 1) {
|
||||
self?.logger.log("Realm schema version was \(oldSchemaVersion)")
|
||||
|
||||
@@ -53,6 +53,11 @@
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UIViewControllerBasedStatusBarAppearance</key>
|
||||
<true/>
|
||||
</dict>
|
||||
|
||||
@@ -159,7 +159,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
|
||||
@objc func sendMetadata() {
|
||||
self.notifyListeners("onPlayingUpdate", data: [ "value": !PlayerHandler.paused ])
|
||||
if let metadata = PlayerHandler.getMetdata() {
|
||||
if let metadata = try? PlayerHandler.getMetdata()?.asDictionary() {
|
||||
self.notifyListeners("onMetadata", data: metadata)
|
||||
}
|
||||
}
|
||||
@@ -211,19 +211,19 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
|
||||
@objc func getSleepTimerTime(_ call: CAPPluginCall) {
|
||||
call.resolve([
|
||||
"value": PlayerHandler.getSleepTimeRemaining()
|
||||
"value": PlayerHandler.getSleepTimeRemaining() ?? 0
|
||||
])
|
||||
}
|
||||
|
||||
@objc func sendSleepTimerEnded() {
|
||||
self.notifyListeners("onSleepTimerEnded", data: [
|
||||
"value": PlayerHandler.getCurrentTime()
|
||||
"value": PlayerHandler.getCurrentTime() ?? 0
|
||||
])
|
||||
}
|
||||
|
||||
@objc func sendSleepTimerSet() {
|
||||
self.notifyListeners("onSleepTimerSet", data: [
|
||||
"value": PlayerHandler.getSleepTimeRemaining()
|
||||
"value": PlayerHandler.getSleepTimeRemaining() ?? 0
|
||||
])
|
||||
}
|
||||
|
||||
@@ -238,6 +238,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
@objc func onPlaybackFailed() {
|
||||
if (PlayerHandler.getPlayMethod() == PlayMethod.directplay.rawValue) {
|
||||
let session = PlayerHandler.getPlaybackSession()
|
||||
let playWhenReady = PlayerHandler.getPlayWhenReady()
|
||||
let libraryItemId = session?.libraryItemId ?? ""
|
||||
let episodeId = session?.episodeId ?? nil
|
||||
logger.log("Forcing Transcode")
|
||||
@@ -247,7 +248,7 @@ public class AbsAudioPlayer: CAPPlugin {
|
||||
do {
|
||||
guard let self = self else { return }
|
||||
try session.save()
|
||||
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: self.initialPlayWhenReady, playbackRate: PlayerSettings.main().playbackRate)
|
||||
PlayerHandler.startPlayback(sessionId: session.id, playWhenReady: playWhenReady, playbackRate: PlayerSettings.main().playbackRate)
|
||||
self.sendPlaybackSession(session: try session.asDictionary())
|
||||
self.sendMetadata()
|
||||
} catch(let exception) {
|
||||
|
||||
@@ -33,6 +33,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||
let realm = try Realm()
|
||||
try realm.write {
|
||||
downloadItemPart.bytesDownloaded = downloadItemPart.fileSize
|
||||
downloadItemPart.progress = 100
|
||||
downloadItemPart.completed = true
|
||||
}
|
||||
@@ -72,9 +73,11 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
handleDownloadTaskUpdate(downloadTask: downloadTask) { downloadItem, downloadItemPart in
|
||||
// Calculate the download percentage
|
||||
let percentDownloaded = (Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)) * 100
|
||||
|
||||
// Only update the progress if we received accurate progress data
|
||||
if percentDownloaded >= 0.0 && percentDownloaded <= 100.0 {
|
||||
try Realm().write {
|
||||
downloadItemPart.bytesDownloaded = Double(totalBytesWritten)
|
||||
downloadItemPart.progress = percentDownloaded
|
||||
}
|
||||
}
|
||||
@@ -109,6 +112,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
// Call the progress handler
|
||||
do {
|
||||
try progressHandler(downloadItem, part)
|
||||
try? self.notifyListeners("onDownloadItemPartUpdate", data: part.asDictionary())
|
||||
} catch {
|
||||
logger.error("Error while processing progress")
|
||||
debugPrint(error)
|
||||
@@ -158,10 +162,9 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit status for active downloads
|
||||
// Check for items done downloading
|
||||
if let activeDownloads = fetchActiveDownloads() {
|
||||
for item in activeDownloads.values {
|
||||
try? self.notifyListeners("onItemDownloadUpdate", data: item.asDictionary())
|
||||
if item.isDoneDownloading() { handleDoneDownloadItem(item) }
|
||||
}
|
||||
}
|
||||
@@ -277,19 +280,22 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
let downloadItem = DownloadItem(libraryItem: item, episodeId: episodeId, server: Store.serverConfig!)
|
||||
var tasks = [DownloadItemPartTask]()
|
||||
for (i, track) in tracks.enumerated() {
|
||||
let task = try startLibraryItemTrackDownload(item: item, position: i, track: track, episode: episode)
|
||||
let task = try startLibraryItemTrackDownload(downloadItemId: downloadItem.id!, item: item, position: i, track: track, episode: episode)
|
||||
downloadItem.downloadItemParts.append(task.part)
|
||||
tasks.append(task)
|
||||
}
|
||||
|
||||
// Also download the cover
|
||||
if item.media?.coverPath != nil && !(item.media?.coverPath!.isEmpty ?? true) {
|
||||
if let task = try? startLibraryItemCoverDownload(item: item) {
|
||||
if let task = try? startLibraryItemCoverDownload(downloadItemId: downloadItem.id!, item: item) {
|
||||
downloadItem.downloadItemParts.append(task.part)
|
||||
tasks.append(task)
|
||||
}
|
||||
}
|
||||
|
||||
// Notify client of download item
|
||||
try? self.notifyListeners("onDownloadItem", data: downloadItem.asDictionary())
|
||||
|
||||
// Persist in the database before status start coming in
|
||||
try Database.shared.saveDownloadItem(downloadItem)
|
||||
|
||||
@@ -299,7 +305,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func startLibraryItemTrackDownload(item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
||||
private func startLibraryItemTrackDownload(downloadItemId: String, item: LibraryItem, position: Int, track: AudioTrack, episode: PodcastEpisode?) throws -> DownloadItemPartTask {
|
||||
logger.log("TRACK \(track.contentUrl!)")
|
||||
|
||||
// If we don't name metadata, then we can't proceed
|
||||
@@ -312,7 +318,7 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
let localUrl = "\(itemDirectory)/\(filename)"
|
||||
|
||||
let task = session.downloadTask(with: serverUrl)
|
||||
let part = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode)
|
||||
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: track.title ?? "Unknown", serverPath: Store.serverConfig!.address, audioTrack: track, episode: episode, size: track.metadata?.size ?? 0)
|
||||
|
||||
// Store the id on the task so the download item can be pulled from the database later
|
||||
task.taskDescription = part.id
|
||||
@@ -320,13 +326,18 @@ public class AbsDownloader: CAPPlugin, URLSessionDownloadDelegate {
|
||||
return DownloadItemPartTask(part: part, task: task)
|
||||
}
|
||||
|
||||
private func startLibraryItemCoverDownload(item: LibraryItem) throws -> DownloadItemPartTask {
|
||||
private func startLibraryItemCoverDownload(downloadItemId: String, item: LibraryItem) throws -> DownloadItemPartTask {
|
||||
let filename = "cover.jpg"
|
||||
let serverPath = "/api/items/\(item.id)/cover"
|
||||
let itemDirectory = try createLibraryItemFileDirectory(item: item)
|
||||
let localUrl = "\(itemDirectory)/\(filename)"
|
||||
|
||||
let part = DownloadItemPart(filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil)
|
||||
// Find library file to get cover size
|
||||
let coverLibraryFile = item.libraryFiles.first(where: {
|
||||
$0.metadata?.path == item.media?.coverPath
|
||||
})
|
||||
|
||||
let part = DownloadItemPart(downloadItemId: downloadItemId, filename: filename, destination: localUrl, itemTitle: "cover", serverPath: serverPath, audioTrack: nil, episode: nil, size: coverLibraryFile?.metadata?.size ?? 0)
|
||||
let task = session.downloadTask(with: part.downloadURL!)
|
||||
|
||||
// Store the id on the task so the download item can be pulled from the database later
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
class PlaybackMetadata: Codable {
|
||||
var duration: Double = 0
|
||||
var currentTime: Double = 0
|
||||
var playerState: PlayerState = PlayerState.IDLE
|
||||
struct PlaybackMetadata: Codable {
|
||||
let duration: Double
|
||||
let currentTime: Double
|
||||
let playerState: PlayerState
|
||||
}
|
||||
|
||||
@@ -7,9 +7,9 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum PlayerState: Codable {
|
||||
case IDLE
|
||||
case BUFFERING
|
||||
case READY
|
||||
case ENDED
|
||||
enum PlayerState: String, Codable {
|
||||
case idle = "IDLE"
|
||||
case buffering = "BUFFERING"
|
||||
case ready = "READY"
|
||||
case ended = "ENDED"
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import Foundation
|
||||
import RealmSwift
|
||||
|
||||
class DownloadItem: Object, Codable {
|
||||
@Persisted(primaryKey: true) var id: String?
|
||||
@Persisted(primaryKey: true) var id:String?
|
||||
@Persisted(indexed: true) var libraryItemId: String?
|
||||
@Persisted var episodeId: String?
|
||||
@Persisted var userMediaProgress: MediaProgress?
|
||||
|
||||
@@ -10,7 +10,9 @@ import RealmSwift
|
||||
|
||||
class DownloadItemPart: Object, Codable {
|
||||
@Persisted(primaryKey: true) var id = ""
|
||||
@Persisted var downloadItemId: String?
|
||||
@Persisted var filename: String?
|
||||
@Persisted var fileSize: Double = 0
|
||||
@Persisted var itemTitle: String?
|
||||
@Persisted var serverPath: String?
|
||||
@Persisted var audioTrack: AudioTrack?
|
||||
@@ -21,9 +23,10 @@ class DownloadItemPart: Object, Codable {
|
||||
@Persisted var uri: String?
|
||||
@Persisted var destinationUri: String?
|
||||
@Persisted var progress: Double = 0
|
||||
@Persisted var bytesDownloaded: Double = 0
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case id, filename, itemTitle, completed, moved, failed, progress
|
||||
case id, downloadItemId, filename, fileSize, itemTitle, completed, moved, failed, progress, bytesDownloaded
|
||||
}
|
||||
|
||||
override init() {
|
||||
@@ -33,32 +36,40 @@ class DownloadItemPart: Object, Codable {
|
||||
required init(from decoder: Decoder) throws {
|
||||
let values = try decoder.container(keyedBy: CodingKeys.self)
|
||||
id = try values.decode(String.self, forKey: .id)
|
||||
downloadItemId = try? values.decode(String.self, forKey: .downloadItemId)
|
||||
filename = try? values.decode(String.self, forKey: .filename)
|
||||
fileSize = try values.decode(Double.self, forKey: .fileSize)
|
||||
itemTitle = try? values.decode(String.self, forKey: .itemTitle)
|
||||
completed = try values.decode(Bool.self, forKey: .completed)
|
||||
moved = try values.decode(Bool.self, forKey: .moved)
|
||||
failed = try values.decode(Bool.self, forKey: .failed)
|
||||
progress = try values.decode(Double.self, forKey: .progress)
|
||||
bytesDownloaded = try values.decode(Double.self, forKey: .bytesDownloaded)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.container(keyedBy: CodingKeys.self)
|
||||
try container.encode(id, forKey: .id)
|
||||
try container.encode(downloadItemId, forKey: .downloadItemId)
|
||||
try container.encode(filename, forKey: .filename)
|
||||
try container.encode(fileSize, forKey: .fileSize)
|
||||
try container.encode(itemTitle, forKey: .itemTitle)
|
||||
try container.encode(completed, forKey: .completed)
|
||||
try container.encode(moved, forKey: .moved)
|
||||
try container.encode(failed, forKey: .failed)
|
||||
try container.encode(progress, forKey: .progress)
|
||||
try container.encode(bytesDownloaded, forKey: .bytesDownloaded)
|
||||
}
|
||||
}
|
||||
|
||||
extension DownloadItemPart {
|
||||
convenience init(filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?) {
|
||||
convenience init(downloadItemId: String, filename: String, destination: String, itemTitle: String, serverPath: String, audioTrack: AudioTrack?, episode: PodcastEpisode?, size: Double) {
|
||||
self.init()
|
||||
|
||||
self.id = destination.toBase64()
|
||||
self.downloadItemId = downloadItemId
|
||||
self.filename = filename
|
||||
self.fileSize = size
|
||||
self.itemTitle = itemTitle
|
||||
self.serverPath = serverPath
|
||||
self.audioTrack = AudioTrack.detachCopy(of: audioTrack)
|
||||
|
||||
@@ -13,9 +13,10 @@ class FileMetadata: EmbeddedObject, Codable {
|
||||
@Persisted var ext: String = ""
|
||||
@Persisted var path: String = ""
|
||||
@Persisted var relPath: String = ""
|
||||
@Persisted var size:Double = 0
|
||||
|
||||
private enum CodingKeys : String, CodingKey {
|
||||
case filename, ext, path, relPath
|
||||
case filename, ext, path, relPath, size
|
||||
}
|
||||
|
||||
override init() {
|
||||
@@ -29,6 +30,7 @@ class FileMetadata: EmbeddedObject, Codable {
|
||||
ext = try values.decode(String.self, forKey: .ext)
|
||||
path = try values.decode(String.self, forKey: .path)
|
||||
relPath = try values.decode(String.self, forKey: .relPath)
|
||||
size = try values.decode(Double.self, forKey: .size)
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
@@ -37,5 +39,6 @@ class FileMetadata: EmbeddedObject, Codable {
|
||||
try container.encode(ext, forKey: .ext)
|
||||
try container.encode(path, forKey: .path)
|
||||
try container.encode(relPath, forKey: .relPath)
|
||||
try container.encode(size, forKey: .size)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,27 +10,32 @@ import AVFoundation
|
||||
import UIKit
|
||||
import MediaPlayer
|
||||
|
||||
enum PlayMethod:Int {
|
||||
enum PlayMethod: Int {
|
||||
case directplay = 0
|
||||
case directstream = 1
|
||||
case transcode = 2
|
||||
case local = 3
|
||||
}
|
||||
|
||||
enum PlayerStatus: Int {
|
||||
case uninitialized = -1
|
||||
case paused = 0
|
||||
case playing = 1
|
||||
}
|
||||
|
||||
class AudioPlayer: NSObject {
|
||||
internal let queue = DispatchQueue(label: "ABSAudioPlayerQueue")
|
||||
internal let logger = AppLogger(category: "AudioPlayer")
|
||||
|
||||
// enums and @objc are not compatible
|
||||
@objc dynamic var status: Int
|
||||
@objc dynamic var rate: Float
|
||||
|
||||
|
||||
private var status: PlayerStatus
|
||||
internal var rate: Float
|
||||
|
||||
private var tmpRate: Float = 1.0
|
||||
|
||||
private var playerContext = 0
|
||||
private var playerItemContext = 0
|
||||
|
||||
private var playWhenReady: Bool
|
||||
internal var playWhenReady: Bool
|
||||
private var initialPlaybackRate: Float
|
||||
|
||||
internal var audioPlayer: AVQueuePlayer
|
||||
@@ -57,9 +62,9 @@ class AudioPlayer: NSObject {
|
||||
self.playWhenReady = playWhenReady
|
||||
self.initialPlaybackRate = playbackRate
|
||||
self.audioPlayer = AVQueuePlayer()
|
||||
self.audioPlayer.automaticallyWaitsToMinimizeStalling = false
|
||||
self.audioPlayer.automaticallyWaitsToMinimizeStalling = true
|
||||
self.sessionId = sessionId
|
||||
self.status = -1
|
||||
self.status = .uninitialized
|
||||
self.rate = 0.0
|
||||
self.tmpRate = playbackRate
|
||||
|
||||
@@ -141,7 +146,7 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
public func isInitialized() -> Bool {
|
||||
return self.status != -1
|
||||
return self.status != .uninitialized
|
||||
}
|
||||
|
||||
public func getPlaybackSession() -> PlaybackSession? {
|
||||
@@ -156,6 +161,9 @@ class AudioPlayer: NSObject {
|
||||
let trackEnd = startOffset + duration
|
||||
if (time < trackEnd.rounded(.down)) {
|
||||
return index
|
||||
} else if (index == self.allPlayerItems.count - 1) {
|
||||
// Seeking past end of last item
|
||||
return index
|
||||
}
|
||||
}
|
||||
return 0
|
||||
@@ -254,7 +262,7 @@ class AudioPlayer: NSObject {
|
||||
logger.log("queueStatusObserver: Current Item Ready to play. PlayWhenReady: \(self.playWhenReady)")
|
||||
|
||||
// Seek the player before initializing, so a currentTime of 0 does not appear in MediaProgress / session
|
||||
let firstReady = self.status < 0
|
||||
let firstReady = self.status == .uninitialized
|
||||
if firstReady && !self.playWhenReady {
|
||||
// Seek is async, and if we call this when also pressing play, we will get weird jumps in the scrub bar depending on timing
|
||||
// Seeking to the correct position happens during play()
|
||||
@@ -267,7 +275,7 @@ class AudioPlayer: NSObject {
|
||||
self.play(allowSeekBack: false, isInitializing: true)
|
||||
} else {
|
||||
// Mark the player as ready
|
||||
self.status = 0
|
||||
self.status = .paused
|
||||
}
|
||||
} else if (playerItem.status == .failed) {
|
||||
logger.error("queueStatusObserver: FAILED \(playerItem.error?.localizedDescription ?? "")")
|
||||
@@ -327,8 +335,8 @@ class AudioPlayer: NSObject {
|
||||
self.audioPlayer.play()
|
||||
self.audioPlayer.rate = self.tmpRate
|
||||
}
|
||||
self.status = 1
|
||||
|
||||
self.status = .playing
|
||||
|
||||
// Update the progress
|
||||
self.updateNowPlaying()
|
||||
|
||||
@@ -351,27 +359,18 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
}
|
||||
|
||||
self.status = 0
|
||||
|
||||
self.status = .paused
|
||||
updateNowPlaying()
|
||||
|
||||
self.startPausedTimer()
|
||||
}
|
||||
|
||||
public func seek(_ to: Double, from: String) {
|
||||
let continuePlaying = rate > 0.0
|
||||
|
||||
self.pause()
|
||||
|
||||
logger.log("SEEK: Seek to \(to) from \(from) and continuePlaying(\(continuePlaying)")
|
||||
|
||||
logger.log("SEEK: Seek to \(to) from \(from)")
|
||||
|
||||
guard let playbackSession = self.getPlaybackSession() else { return }
|
||||
|
||||
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
|
||||
let ctso = currentTrack.startOffset ?? 0.0
|
||||
let trackEnd = ctso + currentTrack.duration
|
||||
logger.log("SEEK: Seek current track END = \(trackEnd)")
|
||||
|
||||
|
||||
let indexOfSeek = getItemIndexForTime(time: to)
|
||||
logger.log("SEEK: Seek to index \(indexOfSeek) | Current index \(self.currentTrackIndex)")
|
||||
|
||||
@@ -383,8 +382,6 @@ class AudioPlayer: NSObject {
|
||||
playbackSession.currentTime = to
|
||||
}
|
||||
|
||||
self.playWhenReady = continuePlaying // Only playWhenReady if already playing
|
||||
self.status = -1
|
||||
let playerItems = self.allPlayerItems[indexOfSeek..<self.allPlayerItems.count]
|
||||
|
||||
DispatchQueue.runOnMainQueue {
|
||||
@@ -393,26 +390,33 @@ class AudioPlayer: NSObject {
|
||||
self.audioPlayer.insert(item, after:self.audioPlayer.items().last)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
seekInCurrentTrack(to: to, playbackSession: playbackSession)
|
||||
|
||||
setupQueueItemStatusObserver()
|
||||
} else {
|
||||
logger.log("SEEK: Seeking in current item \(to)")
|
||||
let currentTrackStartOffset = playbackSession.audioTracks[self.currentTrackIndex].startOffset ?? 0.0
|
||||
let seekTime = to - currentTrackStartOffset
|
||||
|
||||
seekInCurrentTrack(to: to, playbackSession: playbackSession)
|
||||
}
|
||||
|
||||
// Only for use in here where we handle track selection
|
||||
func seekInCurrentTrack(to: Double, playbackSession: PlaybackSession) {
|
||||
let currentTrack = playbackSession.audioTracks[self.currentTrackIndex]
|
||||
let ctso = currentTrack.startOffset ?? 0.0
|
||||
let trackEnd = ctso + currentTrack.duration
|
||||
logger.log("SEEK: Seeking in current item \(to) (track START = \(ctso) END = \(trackEnd))")
|
||||
|
||||
let boundedTime = min(max(to, ctso), trackEnd)
|
||||
let seekTime = boundedTime - ctso
|
||||
|
||||
DispatchQueue.runOnMainQueue {
|
||||
self.audioPlayer.seek(to: CMTime(seconds: seekTime, preferredTimescale: 1000)) { [weak self] completed in
|
||||
self?.logger.log("SEEK: Completion handler called and continuePlaying(\(continuePlaying)")
|
||||
self?.logger.log("SEEK: Completion handler called")
|
||||
guard completed else {
|
||||
self?.logger.log("SEEK: WARNING: seeking not completed (to \(seekTime)")
|
||||
return
|
||||
}
|
||||
guard let self = self else { return }
|
||||
|
||||
if continuePlaying {
|
||||
self.resumePlayback()
|
||||
}
|
||||
|
||||
self.updateNowPlaying()
|
||||
}
|
||||
}
|
||||
@@ -463,9 +467,18 @@ class AudioPlayer: NSObject {
|
||||
}
|
||||
|
||||
public func isPlaying() -> Bool {
|
||||
return self.status > 0
|
||||
return self.status == .playing
|
||||
}
|
||||
|
||||
public func getPlayerState() -> PlayerState {
|
||||
switch status {
|
||||
case .uninitialized:
|
||||
return PlayerState.buffering
|
||||
case .paused, .playing:
|
||||
return PlayerState.ready
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
private func createAsset(itemId:String, track:AudioTrack) -> AVAsset? {
|
||||
guard let playbackSession = self.getPlaybackSession() else { return nil }
|
||||
|
||||
@@ -54,6 +54,10 @@ class PlayerHandler {
|
||||
self.player?.getCurrentTime()
|
||||
}
|
||||
|
||||
public static func getPlayWhenReady() -> Bool {
|
||||
self.player?.playWhenReady ?? false
|
||||
}
|
||||
|
||||
public static func setPlaybackSpeed(speed: Float) {
|
||||
self.player?.setPlaybackRate(speed)
|
||||
}
|
||||
@@ -88,9 +92,8 @@ class PlayerHandler {
|
||||
|
||||
public static func getPlaybackSession() -> PlaybackSession? {
|
||||
guard let player = player else { return nil }
|
||||
guard player.isInitialized() else { return nil }
|
||||
|
||||
return Database.shared.getPlaybackSession(id: player.getPlaybackSessionId())
|
||||
|
||||
return player.getPlaybackSession()
|
||||
}
|
||||
|
||||
public static func seekForward(amount: Double) {
|
||||
@@ -118,16 +121,15 @@ class PlayerHandler {
|
||||
player.seek(amount, from: "handler")
|
||||
}
|
||||
|
||||
public static func getMetdata() -> [String: Any]? {
|
||||
public static func getMetdata() -> PlaybackMetadata? {
|
||||
guard let player = player else { return nil }
|
||||
guard player.isInitialized() else { return nil }
|
||||
|
||||
return [
|
||||
"duration": player.getDuration(),
|
||||
"currentTime": player.getCurrentTime(),
|
||||
"playerState": !paused,
|
||||
"currentRate": player.rate,
|
||||
]
|
||||
|
||||
return PlaybackMetadata(
|
||||
duration: player.getDuration() ?? 0,
|
||||
currentTime: player.getCurrentTime() ?? 0,
|
||||
playerState: player.getPlayerState()
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Helper logic
|
||||
|
||||
@@ -171,7 +171,7 @@ class ApiClient {
|
||||
]
|
||||
]
|
||||
ApiClient.postResource(endpoint: endpoint, parameters: parameters, decodable: PlaybackSession.self) { obj in
|
||||
var session = obj
|
||||
let session = obj
|
||||
|
||||
session.serverConnectionConfigId = Store.serverConfig!.id
|
||||
session.serverAddress = Store.serverConfig!.address
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
<app-appbar />
|
||||
<div id="content" class="overflow-hidden relative" :class="playerIsOpen ? 'playerOpen' : ''">
|
||||
<Nuxt />
|
||||
|
||||
<div v-if="attemptingConnection" class="absolute top-0 left-0 z-50 w-full h-full flex items-center justify-center">
|
||||
<ui-loading-indicator text="Connecting to server..." />
|
||||
</div>
|
||||
</div>
|
||||
<app-audio-player-container ref="streamContainer" />
|
||||
<modals-libraries-modal />
|
||||
@@ -169,10 +173,23 @@ export default {
|
||||
this.$eventBus.$emit('library-changed')
|
||||
this.inittingLibraries = false
|
||||
},
|
||||
async syncLocalSessions() {
|
||||
if (!this.user) {
|
||||
console.log('[default] No need to sync local sessions - not connected to server')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[default] Calling syncLocalSessions')
|
||||
const response = await this.$db.syncLocalSessionsWithServer()
|
||||
if (response && response.error) {
|
||||
console.error('[default] Failed to sync local sessions', response.error)
|
||||
} else {
|
||||
console.log('[default] Successfully synced local sessions')
|
||||
}
|
||||
},
|
||||
async syncLocalMediaProgress() {
|
||||
if (!this.user) {
|
||||
console.log('[default] No need to sync local media progress - not connected to server')
|
||||
this.$store.commit('setLastLocalMediaSyncResults', null)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -180,15 +197,10 @@ export default {
|
||||
const response = await this.$db.syncLocalMediaProgressWithServer()
|
||||
if (!response) {
|
||||
if (this.$platform != 'web') this.$toast.error('Failed to sync local media with server')
|
||||
this.$store.commit('setLastLocalMediaSyncResults', null)
|
||||
return
|
||||
}
|
||||
const { numLocalMediaProgressForServer, numServerProgressUpdates, numLocalProgressUpdates, serverProgressUpdates } = response
|
||||
if (numLocalMediaProgressForServer > 0) {
|
||||
response.syncedAt = Date.now()
|
||||
response.serverConfigName = this.$store.getters['user/getServerConfigName']
|
||||
this.$store.commit('setLastLocalMediaSyncResults', response)
|
||||
|
||||
if (serverProgressUpdates && serverProgressUpdates.length) {
|
||||
serverProgressUpdates.forEach((progress) => {
|
||||
console.log(`[default] Server progress was updated ${progress.id}`)
|
||||
@@ -203,7 +215,6 @@ export default {
|
||||
}
|
||||
} else {
|
||||
console.log('[default] syncLocalMediaProgress No local media progress to sync')
|
||||
this.$store.commit('setLastLocalMediaSyncResults', null)
|
||||
}
|
||||
},
|
||||
userUpdated(user) {
|
||||
@@ -295,7 +306,12 @@ export default {
|
||||
}
|
||||
|
||||
console.log(`[default] finished connection attempt or already connected ${!!this.user}`)
|
||||
await this.syncLocalMediaProgress()
|
||||
if (this.$platform === 'ios') {
|
||||
// TODO: Update ios to not use this
|
||||
await this.syncLocalMediaProgress()
|
||||
} else {
|
||||
await this.syncLocalSessions()
|
||||
}
|
||||
|
||||
this.loadSavedSettings()
|
||||
this.hasMounted = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "audiobookshelf-app",
|
||||
"version": "0.9.61-beta",
|
||||
"version": "0.9.62-beta",
|
||||
"author": "advplyr",
|
||||
"scripts": {
|
||||
"dev": "nuxt --hostname 0.0.0.0 --port 1337",
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<template>
|
||||
<div class="w-full h-full p-4">
|
||||
<ui-text-input-with-label :value="serverConnConfigName" label="Connection Config Name" disabled class="my-2" />
|
||||
<ui-text-input-with-label :value="serverAddress" label="Host" disabled class="my-2" />
|
||||
|
||||
<ui-text-input-with-label :value="username" label="Username" disabled class="my-2" />
|
||||
|
||||
<ui-btn color="primary flex items-center justify-between text-base w-full mt-8" @click="logout">Logout<span class="material-icons" style="font-size: 1.1rem">logout</span></ui-btn>
|
||||
<ui-btn color="primary flex items-center justify-between gap-2 ml-auto text-base mt-8" @click="logout">Logout<span class="material-icons" style="font-size: 1.1rem">logout</span></ui-btn>
|
||||
|
||||
<div class="flex items-center pt-8">
|
||||
<div class="flex-grow" />
|
||||
<p class="pr-2 text-sm font-book text-yellow-400">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf-app" target="_blank">github</a>.</p>
|
||||
<div class="flex justify-end items-center m-4 gap-3 right-0 bottom-0 absolute">
|
||||
<p class="text-smtext-yellow-400 text-right">Report bugs, request features, provide feedback, and contribute on <a class="underline" href="https://github.com/advplyr/audiobookshelf-app" target="_blank">GitHub</a></p>
|
||||
<a href="https://github.com/advplyr/audiobookshelf-app" target="_blank" class="text-white hover:text-gray-200 hover:scale-150 hover:rotate-6 transform duration-500">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24" viewBox="0 0 24 24">
|
||||
<path
|
||||
@@ -17,8 +16,6 @@
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p class="font-mono pt-1 pb-4">{{ $config.version }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -44,9 +41,6 @@ export default {
|
||||
serverConnectionConfig() {
|
||||
return this.$store.state.user.serverConnectionConfig || {}
|
||||
},
|
||||
serverConnConfigName() {
|
||||
return this.serverConnectionConfig.name
|
||||
},
|
||||
serverAddress() {
|
||||
return this.serverConnectionConfig.address
|
||||
}
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
<home-bookshelf-nav-bar />
|
||||
<home-bookshelf-toolbar v-show="!hideToolbar" />
|
||||
<div id="bookshelf-wrapper" class="main-content overflow-y-auto overflow-x-hidden relative" :class="hideToolbar ? 'no-toolbar' : ''">
|
||||
<nuxt-child />
|
||||
<nuxt-child :loading.sync="loading" />
|
||||
</div>
|
||||
<div v-if="loading" class="absolute top-0 left-0 z-50 w-full h-full flex items-center justify-center">
|
||||
<ui-loading-indicator text="Loading..." />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -11,7 +14,9 @@
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hideToolbar() {
|
||||
|
||||
@@ -26,18 +26,17 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="loading" class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
|
||||
<ui-loading-indicator text="Loading Library..." />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
loading: Boolean
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
shelves: [],
|
||||
loading: false,
|
||||
isFirstNetworkConnection: true,
|
||||
lastServerFetch: 0,
|
||||
lastServerFetchLibraryId: null,
|
||||
@@ -77,6 +76,9 @@ export default {
|
||||
networkConnected() {
|
||||
return this.$store.state.networkConnected
|
||||
},
|
||||
isIos() {
|
||||
return this.$platform === 'ios'
|
||||
},
|
||||
currentLibraryName() {
|
||||
return this.$store.getters['libraries/getCurrentLibraryName']
|
||||
},
|
||||
@@ -94,6 +96,14 @@ export default {
|
||||
},
|
||||
localMediaProgress() {
|
||||
return this.$store.state.globals.localMediaProgress
|
||||
},
|
||||
isLoading: {
|
||||
get() {
|
||||
return this.loading
|
||||
},
|
||||
set(val) {
|
||||
this.$emit('update:loading', val)
|
||||
}
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
@@ -210,7 +220,7 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
this.loading = true
|
||||
this.isLoading = true
|
||||
this.shelves = []
|
||||
|
||||
if (this.user && this.currentLibraryId && this.networkConnected) {
|
||||
@@ -226,7 +236,7 @@ export default {
|
||||
this.shelves = localCategories
|
||||
this.lastServerFetch = 0
|
||||
this.lastLocalFetch = Date.now()
|
||||
this.loading = false
|
||||
this.isLoading = false
|
||||
console.log('[categories] Local shelves set from failure', this.shelves.length, this.lastLocalFetch)
|
||||
return
|
||||
}
|
||||
@@ -247,6 +257,11 @@ export default {
|
||||
return cat
|
||||
})
|
||||
|
||||
// TODO: iOS has its own implementation of this. Android & iOS should be consistent here.
|
||||
if (!this.isIos) {
|
||||
this.openMediaPlayerWithMostRecentListening()
|
||||
}
|
||||
|
||||
// Only add the local shelf with the same media type
|
||||
const localShelves = localCategories.filter((cat) => cat.type === this.currentLibraryMediaType && !cat.localOnly)
|
||||
this.shelves.push(...localShelves)
|
||||
@@ -259,7 +274,41 @@ export default {
|
||||
console.log('[categories] Local shelves set', this.shelves.length, this.lastLocalFetch)
|
||||
}
|
||||
|
||||
this.loading = false
|
||||
this.isLoading = false
|
||||
},
|
||||
openMediaPlayerWithMostRecentListening() {
|
||||
// If we don't already have a player open
|
||||
// Try opening the first book from continue-listening without playing it
|
||||
if (this.$store.state.playerLibraryItemId || !this.$store.state.isFirstAudioLoad) return
|
||||
this.$store.commit('setIsFirstAudioLoad', false) // Only run this once on app launch
|
||||
|
||||
const continueListeningShelf = this.shelves.find((cat) => cat.id === 'continue-listening')
|
||||
const mostRecentEntity = continueListeningShelf?.entities?.[0]
|
||||
if (mostRecentEntity) {
|
||||
const playObject = {
|
||||
libraryItemId: mostRecentEntity.id,
|
||||
episodeId: mostRecentEntity.recentEpisode?.id || null,
|
||||
paused: true
|
||||
}
|
||||
|
||||
// Check if there is a local copy
|
||||
if (mostRecentEntity.localLibraryItem) {
|
||||
if (mostRecentEntity.recentEpisode) {
|
||||
// Check if the podcast episode has a local copy
|
||||
const localEpisode = mostRecentEntity.localLibraryItem.media.episodes.find((ep) => ep.serverEpisodeId === mostRecentEntity.recentEpisode.id)
|
||||
if (localEpisode) {
|
||||
playObject.libraryItemId = mostRecentEntity.localLibraryItem.id
|
||||
playObject.episodeId = localEpisode.id
|
||||
playObject.serverLibraryItemId = mostRecentEntity.id
|
||||
playObject.serverEpisodeId = mostRecentEntity.recentEpisode.id
|
||||
}
|
||||
} else {
|
||||
playObject.libraryItemId = mostRecentEntity.localLibraryItem.id
|
||||
playObject.serverLibraryItemId = mostRecentEntity.id
|
||||
}
|
||||
}
|
||||
this.$eventBus.$emit('play-item', playObject)
|
||||
}
|
||||
},
|
||||
libraryChanged() {
|
||||
if (this.currentLibraryId) {
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
</nuxt-link>
|
||||
<div class="absolute top-0 left-0 w-full p-6 flex items-center flex-col justify-center z-0 short:hidden">
|
||||
<img src="/Logo.png" class="h-20 w-20 mb-2" />
|
||||
<h1 class="text-2xl font-book">audiobookshelf</h1>
|
||||
<h1 class="text-2xl">audiobookshelf</h1>
|
||||
</div>
|
||||
<p class="hidden absolute short:block top-1.5 left-12 p-2 font-book text-xl">audiobookshelf</p>
|
||||
<p class="hidden absolute short:block top-1.5 left-12 p-2text-xl">audiobookshelf</p>
|
||||
|
||||
<!-- <p class="absolute bottom-16 left-0 right-0 px-2 text-center text-error"><strong>Important!</strong> This app requires that you are running <u>your own server</u> and does not provide any content.</p> -->
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="w-full h-full px-3 py-4 overflow-y-auto relative bg-bg">
|
||||
<div class="w-full h-full px-3 py-4 overflow-y-auto overflow-x-hidden relative bg-bg">
|
||||
<div class="fixed top-0 left-0 w-full h-full pointer-events-none p-px z-10">
|
||||
<div class="w-full h-full" :style="{ backgroundColor: coverRgb }" />
|
||||
<div class="w-full h-full absolute top-0 left-0" style="background: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%)" />
|
||||
@@ -7,8 +7,15 @@
|
||||
|
||||
<div class="z-10 relative">
|
||||
<div class="w-full flex justify-center relative mb-4">
|
||||
<div style="width: 0; transform: translateX(-50vw); overflow: visible">
|
||||
<div style="width: 150vw; overflow: hidden">
|
||||
<div id="coverBg" style="filter: blur(5vw)">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="relative" @click="showFullscreenCover = true">
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" @imageLoaded="coverImageLoaded" />
|
||||
<covers-book-cover :library-item="libraryItem" :width="coverWidth" :book-cover-aspect-ratio="bookCoverAspectRatio" no-bg @imageLoaded="coverImageLoaded" />
|
||||
<div v-if="!isPodcast" class="absolute bottom-0 left-0 h-1 shadow-sm z-10" :class="userIsFinished ? 'bg-success' : 'bg-yellow-400'" :style="{ width: coverWidth * progressPercent + 'px' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -19,7 +26,8 @@
|
||||
|
||||
<p v-if="seriesList && seriesList.length" class="text-sm text-gray-300 py-0.5">
|
||||
<template v-for="(series, index) in seriesList">
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||
<nuxt-link :key="series.id" :to="`/bookshelf/series/${series.id}`" class="underline">{{ series.text }}</nuxt-link
|
||||
><span :key="`${series.id}-comma`" v-if="index < seriesList.length - 1">, </span>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
@@ -27,7 +35,8 @@
|
||||
<p v-else-if="bookAuthors && bookAuthors.length" class="text-sm text-gray-300 py-0.5">
|
||||
by
|
||||
<template v-for="(author, index) in bookAuthors">
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span>
|
||||
<nuxt-link :key="author.id" :to="`/bookshelf/library?filter=authors.${$encode(author.id)}`" class="underline">{{ author.name }}</nuxt-link
|
||||
><span :key="`${author.id}-comma`" v-if="index < bookAuthors.length - 1">, </span>
|
||||
</template>
|
||||
</p>
|
||||
|
||||
@@ -41,9 +50,6 @@
|
||||
<p class="leading-6">Your Progress: {{ Math.round(progressPercent * 100) }}%</p>
|
||||
<p v-if="progressPercent < 1" class="text-gray-400 text-xs">{{ $elapsedPretty(userTimeRemaining) }} remaining</p>
|
||||
<p v-else class="text-gray-400 text-xs">Finished {{ $formatDate(userProgressFinishedAt) }}</p>
|
||||
<div v-if="!resettingProgress" class="absolute -top-1.5 -right-1.5 p-1 w-5 h-5 rounded-full bg-bg hover:bg-error border border-primary flex items-center justify-center cursor-pointer" @click.stop="clearProgressClick">
|
||||
<span class="material-icons text-sm">close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="isLocal" class="flex mt-4 -mx-1">
|
||||
@@ -78,7 +84,8 @@
|
||||
</div>
|
||||
|
||||
<div v-if="downloadItem" class="py-3">
|
||||
<p class="text-center text-lg">Downloading! ({{ Math.round(downloadItem.itemProgress * 100) }}%)</p>
|
||||
<p v-if="downloadItem.itemProgress == 1" class="text-center text-lg">Download complete. Processing...</p>
|
||||
<p v-else class="text-center text-lg">Downloading! ({{ Math.round(downloadItem.itemProgress * 100) }}%)</p>
|
||||
</div>
|
||||
|
||||
<!-- metadata -->
|
||||
@@ -86,7 +93,8 @@
|
||||
<div v-if="narrators && narrators.length" class="text-white text-opacity-60 uppercase text-sm">Narrators</div>
|
||||
<div v-if="narrators && narrators.length" class="truncate text-sm">
|
||||
<template v-for="(narrator, index) in narrators">
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link><span :key="index" v-if="index < narrators.length - 1">,</span>
|
||||
<nuxt-link :key="narrator" :to="`/bookshelf/library?filter=narrators.${$encode(narrator)}`" class="underline">{{ narrator }}</nuxt-link
|
||||
><span :key="index" v-if="index < narrators.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
@@ -96,7 +104,8 @@
|
||||
<div v-if="genres.length" class="text-white text-opacity-60 uppercase text-sm">Genres</div>
|
||||
<div v-if="genres.length" class="truncate text-sm">
|
||||
<template v-for="(genre, index) in genres">
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link><span :key="index" v-if="index < genres.length - 1">,</span>
|
||||
<nuxt-link :key="genre" :to="`/bookshelf/library?filter=genres.${$encode(genre)}`" class="underline">{{ genre }}</nuxt-link
|
||||
><span :key="index" v-if="index < genres.length - 1">, </span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,7 +127,7 @@
|
||||
</div>
|
||||
|
||||
<div class="w-full py-4">
|
||||
<p class="text-sm">{{ description }}</p>
|
||||
<p class="text-sm text-justify" style="hyphens: auto;">{{ description }}</p>
|
||||
</div>
|
||||
|
||||
<tables-podcast-episodes-table v-if="isPodcast" :library-item="libraryItem" :local-library-item-id="localLibraryItemId" :episodes="episodes" :local-episodes="localLibraryItemEpisodes" :is-local="isLocal" />
|
||||
@@ -290,6 +299,7 @@ export default {
|
||||
return this.$store.getters['user/getToken']
|
||||
},
|
||||
userItemProgress() {
|
||||
if (this.isPodcast) return null
|
||||
if (this.isLocal) return this.$store.getters['globals/getLocalMediaProgressById'](this.libraryItemId)
|
||||
return this.$store.getters['user/getUserMediaProgress'](this.libraryItemId)
|
||||
},
|
||||
@@ -365,33 +375,48 @@ export default {
|
||||
if (!this.isIos) {
|
||||
items.push({
|
||||
text: 'History',
|
||||
value: 'history'
|
||||
value: 'history',
|
||||
icon: 'history'
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
text: this.userIsFinished ? 'Mark as Not Finished' : 'Mark as Finished',
|
||||
value: 'markFinished'
|
||||
})
|
||||
if (!this.userIsFinished) {
|
||||
items.push({
|
||||
text: 'Mark as Finished',
|
||||
value: 'markFinished',
|
||||
icon: 'beenhere'
|
||||
})
|
||||
}
|
||||
|
||||
if (this.progressPercent > 0) {
|
||||
items.push({
|
||||
text: 'Discard Progress',
|
||||
value: 'discardProgress',
|
||||
icon: 'backspace'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (this.localLibraryItemId) {
|
||||
items.push({
|
||||
text: 'Manage Local Files',
|
||||
value: 'manageLocal'
|
||||
value: 'manageLocal',
|
||||
icon: 'folder'
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.isPodcast && this.serverLibraryItemId) {
|
||||
items.push({
|
||||
text: 'Add to Playlist',
|
||||
value: 'playlist'
|
||||
value: 'playlist',
|
||||
icon: 'playlist_add'
|
||||
})
|
||||
}
|
||||
|
||||
items.push({
|
||||
text: 'More Info',
|
||||
value: 'details'
|
||||
value: 'details',
|
||||
icon: 'info'
|
||||
})
|
||||
|
||||
return items
|
||||
@@ -440,6 +465,8 @@ export default {
|
||||
this.toggleFinished()
|
||||
} else if (action === 'history') {
|
||||
this.$router.push(`/media/${this.mediaId}/history?title=${this.title}`)
|
||||
} else if (action === 'discardProgress') {
|
||||
this.clearProgressClick()
|
||||
}
|
||||
},
|
||||
moreButtonPress() {
|
||||
@@ -710,4 +737,8 @@ export default {
|
||||
width: calc(100% - 64px);
|
||||
max-width: calc(100% - 64px);
|
||||
}
|
||||
#coverBg > div {
|
||||
width: 150vw !important;
|
||||
max-width: 150vw !important;
|
||||
}
|
||||
</style>
|
||||
|
||||