Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a555eab63 | ||
|
|
4cdcbf79d7 | ||
|
|
c07b527a1d | ||
|
|
a2031f1d88 | ||
|
|
42fec4831e | ||
|
|
9da21b57c7 | ||
|
|
2d7b539e90 | ||
|
|
4dbaf3c864 | ||
|
|
e6e7ad8266 | ||
|
|
080a08aebc | ||
|
|
8d4831f912 | ||
|
|
72732025ae | ||
|
|
b0c316d2f0 | ||
|
|
f7a2393d99 | ||
|
|
6229a7f0a8 | ||
|
|
a0768da143 | ||
|
|
04b453060d | ||
|
|
4bf75c606a | ||
|
|
4496b1170c | ||
|
|
932bc30172 | ||
|
|
09d13b9b71 | ||
|
|
16df9a191a | ||
|
|
0ddb5d65c2 | ||
|
|
0f99ecabf7 | ||
|
|
01e89cc916 | ||
|
|
fc7867ef22 | ||
|
|
fe7b715062 | ||
|
|
9b3b865878 | ||
|
|
a29e41e677 | ||
|
|
1f8d441ef0 | ||
|
|
658fcb5e8b | ||
|
|
d361b75360 | ||
|
|
fb75a7fefa | ||
|
|
7331389add | ||
|
|
f59e6521fc | ||
|
|
1a7f90c93b | ||
|
|
0fcec4b861 | ||
|
|
f11efd93c7 | ||
|
|
b9de407c8a | ||
|
|
a295889a52 | ||
|
|
6faa9b6324 | ||
|
|
d3a2c79a55 | ||
|
|
6c08e91dfc | ||
|
|
8a542c8776 | ||
|
|
f180fc792f | ||
|
|
ab531edac2 | ||
|
|
3232b519d3 | ||
|
|
37d3021302 | ||
|
|
446c6756ed | ||
|
|
dc8b57f8cf | ||
|
|
a7de535123 | ||
|
|
edc45addc9 | ||
|
|
d3343d722f | ||
|
|
708f399916 | ||
|
|
f5d455feb1 | ||
|
|
6bb8dfeffa | ||
|
|
f40e971b90 | ||
|
|
3b6e7e1ce2 | ||
|
|
b6dd37b7f6 | ||
|
|
fd79baed61 | ||
|
|
2965ccb513 | ||
|
|
0abefbd9bc | ||
|
|
bf8e48fd27 | ||
|
|
52e3ea0a99 | ||
|
|
44f535020d | ||
|
|
ba00acaef5 | ||
|
|
65706a52fc | ||
|
|
08195af0dd | ||
|
|
082c303caf | ||
|
|
3b462b27b5 | ||
|
|
bff99f292b | ||
|
|
77784e4988 | ||
|
|
3f63c4d157 | ||
|
|
5757d90b05 | ||
|
|
37fea5c772 | ||
|
|
ebf628315c | ||
|
|
de4340487b | ||
|
|
55f032d887 | ||
|
|
fde6a4e8ec | ||
|
|
7c87d79591 | ||
|
|
ab8e06283f | ||
|
|
1da0223461 | ||
|
|
2b14460bf1 | ||
|
|
504393ff2b | ||
|
|
ad823ca486 | ||
|
|
4935155078 | ||
|
|
02c0a3428e | ||
|
|
b9de9cf9b0 | ||
|
|
274a683be7 | ||
|
|
4049596025 | ||
|
|
aef09ebada | ||
|
|
cd3277a5f9 | ||
|
|
5a025b3a03 | ||
|
|
3234e7ef06 | ||
|
|
56a70aefaf | ||
|
|
6dbbfdbc04 | ||
|
|
993ff5341d | ||
|
|
a520a1fd8e | ||
|
|
a4ab5c8d87 | ||
|
|
7778c78b97 | ||
|
|
d05cf46574 | ||
|
|
ee118abf59 | ||
|
|
683af098de | ||
|
|
f1287dda07 | ||
|
|
b8063b8d94 | ||
|
|
9c5f79d54f | ||
|
|
e124d3858f | ||
|
|
b4ffe0fc83 | ||
|
|
07fd075619 | ||
|
|
87f1deff19 | ||
|
|
8232cfb796 | ||
|
|
a412c9d359 | ||
|
|
e97218f2e8 | ||
|
|
2c27fb3108 | ||
|
|
4420375f2a | ||
|
|
811fc91fa7 | ||
|
|
b0d1f51678 | ||
|
|
619b6f3686 | ||
|
|
98076927ff | ||
|
|
fda8e9a07a | ||
|
|
06657538a9 | ||
|
|
495af35043 |
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: Bug report
|
||||
about: Create a report to help us improve
|
||||
labels: bug
|
||||
|
||||
---
|
||||
|
||||
### Steps to reproduce
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
### Expected behaviour
|
||||
- Tell us what should happen
|
||||
|
||||
### Actual behaviour
|
||||
- Tell us what happens
|
||||
|
||||
### Environment data
|
||||
|
||||
Audiobookshelf Version:
|
||||
|
||||
- [ ] Android App?
|
||||
- [ ] iOS App?
|
||||
|
||||
#### Android Issue
|
||||
Android version:
|
||||
|
||||
Device model:
|
||||
|
||||
Stock or customized system:
|
||||
|
||||
#### iOS Issue
|
||||
iOS Version:
|
||||
|
||||
iPhone model:
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: Feature request
|
||||
about: Suggest an idea for this project
|
||||
labels: enhancement
|
||||
|
||||
---
|
||||
|
||||
### Is your feature request related to a problem? Please describe.
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
|
||||
### Describe the solution you'd like
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
### Describe alternatives you've considered
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
|
||||
### Additional context
|
||||
Add any other context or screenshots about the feature request here.
|
||||
@@ -89,4 +89,5 @@ sw.*
|
||||
# Vim swap files
|
||||
*.swp
|
||||
|
||||
/resources/
|
||||
/resources/
|
||||
/android/app/release/
|
||||
@@ -1,4 +1,5 @@
|
||||
import { io } from 'socket.io-client'
|
||||
import { Storage } from '@capacitor/storage'
|
||||
import axios from 'axios'
|
||||
import EventEmitter from 'events'
|
||||
|
||||
@@ -13,8 +14,11 @@ class Server extends EventEmitter {
|
||||
|
||||
this.user = null
|
||||
this.connected = false
|
||||
this.initialized = false
|
||||
|
||||
this.stream = null
|
||||
|
||||
this.isConnectingSocket = false
|
||||
}
|
||||
|
||||
get token() {
|
||||
@@ -26,53 +30,90 @@ class Server extends EventEmitter {
|
||||
}
|
||||
|
||||
getServerUrl(url) {
|
||||
var urlObject = new URL(url)
|
||||
return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}`
|
||||
if (!url) return null
|
||||
try {
|
||||
var urlObject = new URL(url)
|
||||
return `${urlObject.protocol}//${urlObject.hostname}:${urlObject.port}`
|
||||
} catch (error) {
|
||||
console.error('Invalid URL', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
setUser(user) {
|
||||
this.user = user
|
||||
this.store.commit('user/setUser', user)
|
||||
if (user) {
|
||||
localStorage.setItem('userToken', user.token)
|
||||
// this.store.commit('user/setSettings', user.settings)
|
||||
Storage.set({ key: 'token', value: user.token })
|
||||
} else {
|
||||
localStorage.removeItem('userToken')
|
||||
Storage.remove({ key: 'token' })
|
||||
}
|
||||
}
|
||||
|
||||
setServerUrl(url) {
|
||||
this.url = url
|
||||
localStorage.setItem('serverUrl', url)
|
||||
this.store.commit('setServerUrl', url)
|
||||
|
||||
if (url) {
|
||||
Storage.set({ key: 'serverUrl', value: url })
|
||||
} else {
|
||||
Storage.remove({ key: 'serverUrl' })
|
||||
}
|
||||
}
|
||||
|
||||
async connect(url, token) {
|
||||
if (this.connected) {
|
||||
console.warn('[SOCKET] Connection already established for ' + this.url)
|
||||
return { success: true }
|
||||
}
|
||||
if (!url) {
|
||||
console.error('Invalid url to connect')
|
||||
return {
|
||||
error: 'Invalid URL'
|
||||
}
|
||||
}
|
||||
|
||||
var serverUrl = this.getServerUrl(url)
|
||||
var res = await this.ping(serverUrl)
|
||||
|
||||
if (!res || !res.success) {
|
||||
this.url = null
|
||||
return false
|
||||
return {
|
||||
error: res ? res.error : 'Unknown Error'
|
||||
}
|
||||
}
|
||||
var authRes = await this.authorize(serverUrl, token)
|
||||
if (!authRes || !authRes.user) {
|
||||
return false
|
||||
if (!authRes || authRes.error) {
|
||||
return {
|
||||
error: authRes ? authRes.error : 'Authorization Error'
|
||||
}
|
||||
}
|
||||
|
||||
this.setServerUrl(serverUrl)
|
||||
console.warn('Connect setting auth user', authRes)
|
||||
|
||||
this.setUser(authRes.user)
|
||||
this.connectSocket()
|
||||
|
||||
return true
|
||||
return { success: true }
|
||||
}
|
||||
|
||||
async check(url) {
|
||||
var serverUrl = this.getServerUrl(url)
|
||||
var res = await this.ping(serverUrl)
|
||||
if (!res || !res.success) {
|
||||
return false
|
||||
if (!serverUrl) {
|
||||
return {
|
||||
error: 'Invalid server url'
|
||||
}
|
||||
}
|
||||
var res = await this.ping(serverUrl)
|
||||
if (!res || res.error) {
|
||||
return {
|
||||
error: res ? res.error : 'Ping Failed'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
serverUrl
|
||||
}
|
||||
return serverUrl
|
||||
}
|
||||
|
||||
async login(url, username, password) {
|
||||
@@ -94,14 +135,27 @@ class Server extends EventEmitter {
|
||||
}
|
||||
}).catch(error => {
|
||||
console.error('[Server] Server auth failed', error)
|
||||
var errorMsg = null
|
||||
if (error.response) {
|
||||
errorMsg = error.response.data || 'Unknown Error'
|
||||
} else if (error.request) {
|
||||
errorMsg = 'Server did not respond'
|
||||
} else {
|
||||
errorMsg = 'Failed to send request'
|
||||
}
|
||||
return {
|
||||
error: 'Request Failed'
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
logout() {
|
||||
this.setUser(null)
|
||||
this.stream = null
|
||||
if (this.socket) {
|
||||
this.socket.disconnect()
|
||||
}
|
||||
this.emit('logout')
|
||||
}
|
||||
|
||||
authorize(serverUrl, token) {
|
||||
@@ -110,44 +164,139 @@ class Server extends EventEmitter {
|
||||
return res.data
|
||||
}).catch(error => {
|
||||
console.error('[Server] Server auth failed', error)
|
||||
return false
|
||||
var errorMsg = null
|
||||
if (error.response) {
|
||||
errorMsg = error.response.data || 'Unknown Error'
|
||||
} else if (error.request) {
|
||||
errorMsg = 'Server did not respond'
|
||||
} else {
|
||||
errorMsg = 'Failed to send request'
|
||||
}
|
||||
return {
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ping(url) {
|
||||
var pingUrl = url + '/ping'
|
||||
console.log('[Server] Check server', pingUrl)
|
||||
return axios.get(pingUrl).then((res) => {
|
||||
return axios.get(pingUrl, { timeout: 1000 }).then((res) => {
|
||||
return res.data
|
||||
}).catch(error => {
|
||||
console.error('Server check failed', error)
|
||||
return false
|
||||
var errorMsg = null
|
||||
if (error.response) {
|
||||
errorMsg = error.response.data || 'Unknown Error'
|
||||
} else if (error.request) {
|
||||
errorMsg = 'Server did not respond'
|
||||
} else {
|
||||
errorMsg = 'Failed to send request'
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
connectSocket() {
|
||||
console.log('[SERVER] Connect Socket', this.url)
|
||||
if (this.socket && !this.connected) {
|
||||
this.socket.connect()
|
||||
console.log('[SOCKET] Submitting connect')
|
||||
return
|
||||
}
|
||||
if (this.connected || this.socket) {
|
||||
if (this.socket) console.error('[SOCKET] Socket already established', this.url)
|
||||
else console.error('[SOCKET] Already connected to socket', this.url)
|
||||
return
|
||||
}
|
||||
|
||||
this.socket = io(this.url)
|
||||
console.log('[SOCKET] Connect Socket', this.url)
|
||||
|
||||
const socketOptions = {
|
||||
transports: ['websocket'],
|
||||
upgrade: false,
|
||||
// reconnectionAttempts: 3
|
||||
}
|
||||
this.socket = io(this.url, socketOptions)
|
||||
this.socket.on('connect', () => {
|
||||
console.log('[Server] Socket Connected')
|
||||
console.log('[SOCKET] Socket Connected ' + this.socket.id)
|
||||
|
||||
// Authenticate socket with token
|
||||
this.socket.emit('auth', this.token)
|
||||
|
||||
this.connected = true
|
||||
this.emit('connected', true)
|
||||
this.store.commit('setSocketConnected', true)
|
||||
})
|
||||
this.socket.on('disconnect', () => {
|
||||
console.log('[Server] Socket Disconnected')
|
||||
this.socket.on('disconnect', (reason) => {
|
||||
console.log('[SOCKET] Socket Disconnected: ' + reason)
|
||||
this.connected = false
|
||||
this.emit('connected', false)
|
||||
this.emit('initialized', false)
|
||||
this.initialized = false
|
||||
this.store.commit('setSocketConnected', false)
|
||||
|
||||
// this.socket.removeAllListeners()
|
||||
// if (this.socket.io && this.socket.io.removeAllListeners) {
|
||||
// console.log(`[SOCKET] Removing ALL IO listeners`)
|
||||
// this.socket.io.removeAllListeners()
|
||||
// }
|
||||
})
|
||||
this.socket.on('init', (data) => {
|
||||
console.log('[Server] Initial socket data received', data)
|
||||
console.log('[SOCKET] Initial socket data received', data)
|
||||
if (data.stream) {
|
||||
this.stream = data.stream
|
||||
this.store.commit('setStreamAudiobook', data.stream.audiobook)
|
||||
this.emit('initialStream', data.stream)
|
||||
}
|
||||
if (data.serverSettings) {
|
||||
this.store.commit('setServerSettings', data.serverSettings)
|
||||
}
|
||||
this.initialized = true
|
||||
this.emit('initialized', true)
|
||||
})
|
||||
|
||||
this.socket.on('user_updated', (user) => {
|
||||
if (this.user && user.id === this.user.id) {
|
||||
this.setUser(user)
|
||||
}
|
||||
})
|
||||
|
||||
this.socket.on('current_user_audiobook_update', (payload) => {
|
||||
this.emit('currentUserAudiobookUpdate', payload)
|
||||
})
|
||||
|
||||
this.socket.on('show_error_toast', (payload) => {
|
||||
this.emit('show_error_toast', payload)
|
||||
})
|
||||
this.socket.on('show_success_toast', (payload) => {
|
||||
this.emit('show_success_toast', payload)
|
||||
})
|
||||
|
||||
this.socket.onAny((evt, args) => {
|
||||
console.log(`[SOCKET] ${this.socket.id}: ${evt} ${JSON.stringify(args)}`)
|
||||
})
|
||||
|
||||
this.socket.on('connect_error', (err) => {
|
||||
console.error('[SOCKET] connection failed', err)
|
||||
this.emit('socketConnectionFailed', err)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect_attempt", (attempt) => {
|
||||
console.log(`[SOCKET] Reconnect Attempt ${this.socket.id}: ${attempt}`)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect_error", (err) => {
|
||||
console.log(`[SOCKET] Reconnect Error ${this.socket.id}: ${err}`)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect_failed", () => {
|
||||
console.log(`[SOCKET] Reconnect Failed ${this.socket.id}`)
|
||||
})
|
||||
|
||||
this.socket.io.on("reconnect", () => {
|
||||
console.log(`[SOCKET] Reconnect Success ${this.socket.id}`)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
//apply plugin: 'com.android.application'
|
||||
//apply plugin: 'kotlin-android'
|
||||
|
||||
plugins {
|
||||
id 'com.android.application'
|
||||
id 'kotlin-android'
|
||||
@@ -8,13 +5,16 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
kotlinOptions {
|
||||
freeCompilerArgs = ['-Xjvm-default=all']
|
||||
}
|
||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||
defaultConfig {
|
||||
applicationId "com.audiobookshelf.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
versionCode 58
|
||||
versionName "0.9.38-beta"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
@@ -38,18 +38,21 @@ repositories {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation project(':capacitor-android')
|
||||
implementation "com.anggrayudi:storage:0.13.0"
|
||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||
implementation project(':capacitor-android')
|
||||
|
||||
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.1.0'
|
||||
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||
|
||||
implementation "androidx.core:core-ktx:+"
|
||||
testImplementation "junit:junit:$junitVersion"
|
||||
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
|
||||
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
|
||||
implementation project(':capacitor-cordova-android-plugins')
|
||||
|
||||
implementation "androidx.core:core-ktx:1.6.0"
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
|
||||
@@ -78,12 +81,3 @@ dependencies {
|
||||
}
|
||||
|
||||
apply from: 'capacitor.build.gradle'
|
||||
|
||||
try {
|
||||
def servicesJSON = file('google-services.json')
|
||||
if (servicesJSON.text) {
|
||||
apply plugin: 'com.google.gms.google-services'
|
||||
}
|
||||
} catch(Exception e) {
|
||||
logger.warn("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
|
||||
}
|
||||
|
||||
@@ -9,8 +9,14 @@ android {
|
||||
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-community-sqlite')
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-dialog')
|
||||
implementation project(':capacitor-toast')
|
||||
implementation project(':capacitor-network')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capacitor-storage')
|
||||
implementation project(':robingenz-capacitor-app-update')
|
||||
implementation project(':capacitor-data-storage-sqlite')
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:dist="http://schemas.android.com/apk/distribution"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="com.audiobookshelf.app">
|
||||
|
||||
<!-- Permissions -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
@@ -14,31 +17,49 @@
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:usesCleartextTraffic="true"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
|
||||
|
||||
<!--Used by Android Auto-->
|
||||
<meta-data android:name="com.google.android.gms.car.notification.SmallIcon"
|
||||
android:resource="@drawable/icon" />
|
||||
<meta-data
|
||||
android:name="com.google.android.gms.car.application"
|
||||
android:resource="@xml/automotive_app_desc"/>
|
||||
|
||||
<!-- Support for Cast -->
|
||||
<meta-data android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
|
||||
android:value="com.audiobookshelf.app.CastOptionsProvider"/>
|
||||
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:name="com.audiobookshelf.app.MainActivity"
|
||||
android:label="@string/title_activity_main"
|
||||
android:exported="true"
|
||||
android:theme="@style/AppTheme.NoActionBarLaunch"
|
||||
android:launchMode="singleTask">
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
|
||||
<intent-filter>
|
||||
<action android:name="android.media.action.MEDIA_PLAY_FROM_SEARCH" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
|
||||
<!-- Register URL scheme -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="@string/custom_url_scheme" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||
<intent-filter>
|
||||
@@ -47,8 +68,12 @@
|
||||
</receiver>
|
||||
|
||||
<service
|
||||
android:exported="true"
|
||||
android:enabled="true"
|
||||
android:name=".PlayerNotificationService">
|
||||
<intent-filter>
|
||||
<action android:name="android.media.browse.MediaBrowserService"/>
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
|
||||
@@ -1,10 +1,34 @@
|
||||
[
|
||||
{
|
||||
"pkg": "@capacitor-community/sqlite",
|
||||
"classpath": "com.getcapacitor.community.database.sqlite.CapacitorSQLitePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/dialog",
|
||||
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/toast",
|
||||
"classpath": "com.capacitorjs.plugins.toast.ToastPlugin"
|
||||
"pkg": "@capacitor/network",
|
||||
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/status-bar",
|
||||
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/storage",
|
||||
"classpath": "com.capacitorjs.plugins.storage.StoragePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@robingenz/capacitor-app-update",
|
||||
"classpath": "dev.robingenz.capacitor.appupdate.AppUpdatePlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "capacitor-data-storage-sqlite",
|
||||
"classpath": "com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlitePlugin"
|
||||
}
|
||||
]
|
||||
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -0,0 +1,82 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.graphics.Bitmap
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.session.MediaControllerCompat
|
||||
import android.util.Log
|
||||
import com.bumptech.glide.Glide
|
||||
import com.bumptech.glide.load.engine.DiskCacheStrategy
|
||||
import com.bumptech.glide.request.RequestOptions
|
||||
import com.google.android.exoplayer2.Player
|
||||
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||
import kotlinx.coroutines.*
|
||||
|
||||
const val NOTIFICATION_LARGE_ICON_SIZE = 144 // px
|
||||
|
||||
class AbMediaDescriptionAdapter constructor(private val controller: MediaControllerCompat, playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||
private val tag = "MediaDescriptionAdapter"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
|
||||
var currentIconUri: Uri? = null
|
||||
var currentBitmap: Bitmap? = null
|
||||
|
||||
private val glideOptions = RequestOptions()
|
||||
.fallback(R.drawable.icon)
|
||||
.diskCacheStrategy(DiskCacheStrategy.DATA)
|
||||
private val serviceJob = SupervisorJob()
|
||||
private val serviceScope = CoroutineScope(Dispatchers.Main + serviceJob)
|
||||
|
||||
override fun createCurrentContentIntent(player: Player): PendingIntent? =
|
||||
controller.sessionActivity
|
||||
|
||||
override fun getCurrentContentText(player: Player) = controller.metadata.description.subtitle.toString()
|
||||
|
||||
override fun getCurrentContentTitle(player: Player) = controller.metadata.description.title.toString()
|
||||
|
||||
override fun getCurrentLargeIcon(
|
||||
player: Player,
|
||||
callback: PlayerNotificationManager.BitmapCallback
|
||||
): Bitmap? {
|
||||
val albumArtUri = controller.metadata.description.iconUri
|
||||
|
||||
return if (currentIconUri != albumArtUri || currentBitmap == null) {
|
||||
// Cache the bitmap for the current audiobook so that successive calls to
|
||||
// `getCurrentLargeIcon` don't cause the bitmap to be recreated.
|
||||
currentIconUri = albumArtUri
|
||||
Log.d(tag, "ART $currentIconUri")
|
||||
serviceScope.launch {
|
||||
currentBitmap = albumArtUri?.let {
|
||||
resolveUriAsBitmap(it)
|
||||
}
|
||||
currentBitmap?.let { callback.onBitmap(it) }
|
||||
}
|
||||
null
|
||||
} else {
|
||||
currentBitmap
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun resolveUriAsBitmap(uri: Uri): Bitmap? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
// Block on downloading artwork.
|
||||
try {
|
||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
||||
.asBitmap()
|
||||
.load(uri)
|
||||
.placeholder(R.drawable.icon)
|
||||
.error(R.drawable.icon)
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.get()
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
|
||||
Glide.with(playerNotificationService).applyDefaultRequestOptions(glideOptions)
|
||||
.asBitmap()
|
||||
.load(Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon))
|
||||
.submit(NOTIFICATION_LARGE_ICON_SIZE, NOTIFICATION_LARGE_ICON_SIZE)
|
||||
.get()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
import com.anggrayudi.storage.callback.FileCallback
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.anggrayudi.storage.media.FileDescription
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import java.io.File
|
||||
|
||||
|
||||
@CapacitorPlugin(name = "AudioDownloader")
|
||||
class AudioDownloader : Plugin() {
|
||||
private val tag = "AudioDownloader"
|
||||
|
||||
lateinit var mainActivity:MainActivity
|
||||
lateinit var downloadManager:DownloadManager
|
||||
|
||||
// data class AudiobookItem(val uri: Uri, val name: String, val size: Long, val coverUrl: String) {
|
||||
// fun toJSObject() : JSObject {
|
||||
// var obj = JSObject()
|
||||
// obj.put("uri", this.uri)
|
||||
// obj.put("name", this.name)
|
||||
// obj.put("size", this.size)
|
||||
// obj.put("coverUrl", this.coverUrl)
|
||||
// return obj
|
||||
// }
|
||||
// }
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
|
||||
var recieverEvent: (evt: String, id: Long) -> Unit = { evt: String, id: Long ->
|
||||
if (evt == "complete") {
|
||||
}
|
||||
if (evt == "clicked") {
|
||||
Log.d(tag, "Clicked $id back in the audiodownloader")
|
||||
}
|
||||
}
|
||||
mainActivity.registerBroadcastReceiver(recieverEvent)
|
||||
|
||||
Log.d(tag, "Build SDK ${Build.VERSION.SDK_INT}")
|
||||
}
|
||||
|
||||
|
||||
// @PluginMethod
|
||||
// fun load(call: PluginCall) {
|
||||
// var audiobookUrls = call.data.getJSONArray("audiobookUrls")
|
||||
// var len = audiobookUrls?.length()
|
||||
// if (len == null) {
|
||||
// len = 0
|
||||
// }
|
||||
// Log.d(tag, "CALLED LOAD $len")
|
||||
// var audiobookItems:MutableList<AudiobookItem> = mutableListOf()
|
||||
//
|
||||
// (0 until len).forEach {
|
||||
// var jsobj = audiobookUrls.get(it) as JSONObject
|
||||
// var audiobookUrl = jsobj.get("contentUrl").toString()
|
||||
// var coverUrl = jsobj.get("coverUrl").toString()
|
||||
// var storageId = ""
|
||||
// if(jsobj.has("storageId")) jsobj.get("storageId").toString()
|
||||
//
|
||||
// var basePath = ""
|
||||
// if(jsobj.has("basePath")) jsobj.get("basePath").toString()
|
||||
//
|
||||
// var coverBasePath = ""
|
||||
// if(jsobj.has("coverBasePath")) jsobj.get("coverBasePath").toString()
|
||||
//
|
||||
// Log.d(tag, "LOOKUP $storageId $basePath $audiobookUrl")
|
||||
//
|
||||
// var audiobookFile: DocumentFile? = null
|
||||
// var coverFile: DocumentFile? = null
|
||||
//
|
||||
// // Android 9 OR Below use storage id and base path
|
||||
// if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
||||
// audiobookFile = DocumentFileCompat.fromSimplePath(context, storageId, basePath)
|
||||
// if (coverUrl != null && coverUrl != "") {
|
||||
// coverFile = DocumentFileCompat.fromSimplePath(context, storageId, coverBasePath)
|
||||
// }
|
||||
// } else {
|
||||
// // Android 10 and up manually deleting will still load the file causing crash
|
||||
// var exists = checkUriExists(Uri.parse(audiobookUrl))
|
||||
// if (exists) {
|
||||
// Log.d(tag, "Audiobook exists")
|
||||
// audiobookFile = DocumentFileCompat.fromUri(context, Uri.parse(audiobookUrl))
|
||||
// } else {
|
||||
// Log.e(tag, "Audiobook does not exist")
|
||||
// }
|
||||
//
|
||||
// var coverExists = checkUriExists(Uri.parse(coverUrl))
|
||||
// if (coverExists) {
|
||||
// Log.d(tag, "Cover Exists")
|
||||
// coverFile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
|
||||
// } else if (coverUrl != null && coverUrl != "") {
|
||||
// Log.e(tag, "Cover does not exist")
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// if (audiobookFile == null) {
|
||||
// Log.e(tag, "Audiobook was not found $audiobookUrl")
|
||||
// } else {
|
||||
// Log.d(tag, "Audiobook File Found StorageId:${audiobookFile.getStorageId(context)} | AbsolutePath:${audiobookFile.getAbsolutePath(context)} | BasePath:${audiobookFile.getBasePath(context)}")
|
||||
//
|
||||
// var _name = audiobookFile.name
|
||||
// if (_name == null) _name = ""
|
||||
//
|
||||
// var size = audiobookFile.length()
|
||||
//
|
||||
// if (audiobookFile.uri.toString() !== audiobookUrl) {
|
||||
// Log.d(tag, "Audiobook URI ${audiobookFile.uri} is different from $audiobookUrl => using the latter")
|
||||
// }
|
||||
//
|
||||
// // Use existing URI's - bug happening where new uri is different from initial
|
||||
// var abItem = AudiobookItem(Uri.parse(audiobookUrl), _name, size, coverUrl)
|
||||
//
|
||||
// Log.d(tag, "Setting AB ITEM ${abItem.name} | ${abItem.size} | ${abItem.uri} | ${abItem.coverUrl}")
|
||||
//
|
||||
// audiobookItems.add(abItem)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Log.d(tag, "Load Finished ${audiobookItems.size} found")
|
||||
//
|
||||
// var audiobookObjs:List<JSObject> = audiobookItems.map{ it.toJSObject() }
|
||||
// var mediaItemNoticePayload = JSObject()
|
||||
// mediaItemNoticePayload.put("items", audiobookObjs)
|
||||
// notifyListeners("onMediaLoaded", mediaItemNoticePayload)
|
||||
// }
|
||||
|
||||
@PluginMethod
|
||||
fun download(call: PluginCall) {
|
||||
var audiobookId = call.data.getString("audiobookId", "audiobook").toString()
|
||||
var url = call.data.getString("downloadUrl", "unknown").toString()
|
||||
var coverDownloadUrl = call.data.getString("coverDownloadUrl", "").toString()
|
||||
var title = call.data.getString("title", "Audiobook").toString()
|
||||
var filename = call.data.getString("filename", "audiobook.mp3").toString()
|
||||
var coverFilename = call.data.getString("coverFilename", "cover.png").toString()
|
||||
var downloadFolderUrl = call.data.getString("downloadFolderUrl", "").toString()
|
||||
var folder = DocumentFileCompat.fromUri(context, Uri.parse(downloadFolderUrl))!!
|
||||
Log.d(tag, "Called download: $url | Folder: ${folder.name} | $downloadFolderUrl")
|
||||
|
||||
var dlfilename = audiobookId + "." + File(filename).extension
|
||||
var coverdlfilename = audiobookId + "." + File(coverFilename).extension
|
||||
Log.d(tag, "DL Filename $dlfilename | Cover DL Filename $coverdlfilename")
|
||||
|
||||
var canWriteToFolder = folder.canWrite()
|
||||
if (!canWriteToFolder) {
|
||||
Log.e(tag, "Error Cannot Write to Folder ${folder.baseName}")
|
||||
val ret = JSObject()
|
||||
ret.put("error", "Cannot write to ${folder.baseName}")
|
||||
call.resolve(ret)
|
||||
return
|
||||
}
|
||||
|
||||
var dlRequest = DownloadManager.Request(Uri.parse(url))
|
||||
dlRequest.setTitle("Ab: $title")
|
||||
dlRequest.setDescription("Downloading to ${folder.name}")
|
||||
dlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
|
||||
dlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, dlfilename)
|
||||
|
||||
var audiobookDownloadId = downloadManager.enqueue(dlRequest)
|
||||
var coverDownloadId:Long? = null
|
||||
|
||||
if (coverDownloadUrl != "") {
|
||||
var coverDlRequest = DownloadManager.Request(Uri.parse(coverDownloadUrl))
|
||||
coverDlRequest.setTitle("Cover: $title")
|
||||
coverDlRequest.setDescription("Downloading to ${folder.name}")
|
||||
coverDlRequest.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_ONLY_COMPLETION)
|
||||
coverDlRequest.setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, coverdlfilename)
|
||||
coverDownloadId = downloadManager.enqueue(coverDlRequest)
|
||||
}
|
||||
|
||||
var progressReceiver : (id:Long, prog: Long) -> Unit = { id:Long, prog: Long ->
|
||||
if (id == audiobookDownloadId) {
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("progress", prog)
|
||||
notifyListeners("onDownloadProgress", jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
var coverDocFile:DocumentFile? = null
|
||||
|
||||
var doneReceiver : (id:Long, success: Boolean) -> Unit = { id:Long, success: Boolean ->
|
||||
Log.d(tag, "RECEIVER DONE $id, SUCCES? $success")
|
||||
var docfile:DocumentFile? = null
|
||||
|
||||
// Download was complete, now find downloaded file
|
||||
if (id == coverDownloadId) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, coverdlfilename)
|
||||
Log.d(tag, "Move Cover File ${docfile?.name}")
|
||||
|
||||
// For unknown reason, Android 10 test was using the title set in "setTitle" for the dl manager as the filename
|
||||
// check if this was the case
|
||||
if (docfile?.name == null) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Cover: $title")
|
||||
Log.d(tag, "Cover File name attempt 2 ${docfile?.name}")
|
||||
}
|
||||
} else if (id == audiobookDownloadId) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, dlfilename)
|
||||
Log.d(tag, "Move Audiobook File ${docfile?.name}")
|
||||
|
||||
if (docfile?.name == null) {
|
||||
docfile = DocumentFileCompat.fromPublicFolder(context, PublicDirectory.DOWNLOADS, "Ab: $title")
|
||||
Log.d(tag, "File name attempt 2 ${docfile?.name}")
|
||||
}
|
||||
}
|
||||
|
||||
// Callback for moving the downloaded file
|
||||
var callback = object : FileCallback() {
|
||||
override fun onPrepare() {
|
||||
Log.d(tag, "PREPARING MOVE FILE")
|
||||
}
|
||||
override fun onFailed(errorCode:ErrorCode) {
|
||||
Log.e(tag, "FAILED MOVE FILE $errorCode")
|
||||
|
||||
docfile?.delete()
|
||||
coverDocFile?.delete()
|
||||
|
||||
if (id == audiobookDownloadId) {
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("error", "Move failed")
|
||||
notifyListeners("onDownloadFailed", jsobj)
|
||||
}
|
||||
}
|
||||
override fun onCompleted(result:Any) {
|
||||
var resultDocFile = result as DocumentFile
|
||||
var simplePath = resultDocFile.getSimplePath(context)
|
||||
var storageId = resultDocFile.getStorageId(context)
|
||||
var size = resultDocFile.length()
|
||||
Log.d(tag, "Finished Moving File, NAME: ${resultDocFile.name} | URI:${resultDocFile.uri} | AbsolutePath:${resultDocFile.getAbsolutePath(context)} | $storageId | SimplePath: $simplePath")
|
||||
|
||||
var abFolder = folder.findFolder(title)
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("downloadId", id)
|
||||
jsobj.put("storageId", storageId)
|
||||
jsobj.put("storageType", resultDocFile.getStorageType(context))
|
||||
jsobj.put("folderUrl", abFolder?.uri)
|
||||
jsobj.put("folderName", abFolder?.name)
|
||||
jsobj.put("downloadFolderUrl", downloadFolderUrl)
|
||||
jsobj.put("contentUrl", resultDocFile.uri)
|
||||
jsobj.put("basePath", resultDocFile.getBasePath(context))
|
||||
jsobj.put("filename", filename)
|
||||
jsobj.put("simplePath", simplePath)
|
||||
jsobj.put("size", size)
|
||||
|
||||
if (resultDocFile.name == filename) {
|
||||
Log.d(tag, "Audiobook Finishing Moving")
|
||||
} else if (resultDocFile.name == coverFilename) {
|
||||
coverDocFile = docfile
|
||||
Log.d(tag, "Audiobook Cover Finished Moving")
|
||||
jsobj.put("isCover", true)
|
||||
}
|
||||
notifyListeners("onDownloadComplete", jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
// After file is downloaded, move the files into an audiobook directory inside the user selected folder
|
||||
if (id == coverDownloadId) {
|
||||
docfile?.moveFileTo(context, folder, FileDescription(coverFilename, title, MimeType.IMAGE), callback)
|
||||
} else if (id == audiobookDownloadId) {
|
||||
docfile?.moveFileTo(context, folder, FileDescription(filename, title, MimeType.AUDIO), callback)
|
||||
}
|
||||
}
|
||||
|
||||
var progressUpdater = DownloadProgressUpdater(downloadManager, audiobookDownloadId, progressReceiver, doneReceiver)
|
||||
progressUpdater.run()
|
||||
if (coverDownloadId != null) {
|
||||
var coverProgressUpdater = DownloadProgressUpdater(downloadManager, coverDownloadId, progressReceiver, doneReceiver)
|
||||
coverProgressUpdater.run()
|
||||
}
|
||||
|
||||
val ret = JSObject()
|
||||
ret.put("audiobookDownloadId", audiobookDownloadId)
|
||||
ret.put("coverDownloadId", coverDownloadId)
|
||||
call.resolve(ret)
|
||||
}
|
||||
|
||||
internal class DownloadProgressUpdater(private val manager: DownloadManager, private val downloadId: Long, private var receiver: (Long, Long) -> Unit, private var doneReceiver: (Long, Boolean) -> Unit) : Thread() {
|
||||
private val query: DownloadManager.Query = DownloadManager.Query()
|
||||
private var totalBytes: Int = 0
|
||||
private var TAG = "DownloadProgressUpdater"
|
||||
|
||||
init {
|
||||
query.setFilterById(this.downloadId)
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
Log.d(TAG, "RUN FOR ID $downloadId")
|
||||
var keepRunning = true
|
||||
var increment = 0
|
||||
while (keepRunning) {
|
||||
Thread.sleep(500)
|
||||
increment++
|
||||
|
||||
if (increment % 4 == 0) {
|
||||
Log.d(TAG, "Loop $increment : $downloadId")
|
||||
}
|
||||
|
||||
manager.query(query).use {
|
||||
if (it.moveToFirst()) {
|
||||
//get total bytes of the file
|
||||
if (totalBytes <= 0) {
|
||||
totalBytes = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES))
|
||||
if (totalBytes <= 0) {
|
||||
Log.e(TAG, "Download Is 0 Bytes $downloadId")
|
||||
doneReceiver(downloadId, false)
|
||||
keepRunning = false
|
||||
this.interrupt()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
val downloadStatus = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_STATUS))
|
||||
val bytesDownloadedSoFar = it.getInt(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR))
|
||||
|
||||
if (increment % 4 == 0) {
|
||||
Log.d(TAG, "BYTES $increment : $downloadId : $bytesDownloadedSoFar : TOTAL: $totalBytes")
|
||||
}
|
||||
|
||||
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL || downloadStatus == DownloadManager.STATUS_FAILED) {
|
||||
if (downloadStatus == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
doneReceiver(downloadId, true)
|
||||
} else {
|
||||
doneReceiver(downloadId, false)
|
||||
}
|
||||
keepRunning = false
|
||||
this.interrupt()
|
||||
} else {
|
||||
//update progress
|
||||
val percentProgress = ((bytesDownloadedSoFar * 100L) / totalBytes)
|
||||
receiver(downloadId, percentProgress)
|
||||
}
|
||||
} else {
|
||||
Log.e(TAG, "NOT FOUND IN QUERY")
|
||||
keepRunning = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +1,102 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.support.v4.media.MediaBrowserCompat
|
||||
import android.support.v4.media.MediaDescriptionCompat
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import com.getcapacitor.JSObject
|
||||
|
||||
class Audiobook {
|
||||
var id:String = "audiobook"
|
||||
var id:String
|
||||
var ino:String
|
||||
var libraryId:String
|
||||
var folderId:String
|
||||
var book:Book
|
||||
var duration:Float
|
||||
var size:Long
|
||||
var numTracks:Int
|
||||
var isMissing:Boolean
|
||||
var isInvalid:Boolean
|
||||
var path:String
|
||||
|
||||
var isDownloaded:Boolean = false
|
||||
var downloadFolderUrl:String = ""
|
||||
var folderUrl:String = ""
|
||||
var contentUrl:String = ""
|
||||
var filename:String = ""
|
||||
var localCoverUrl:String = ""
|
||||
var localCover:String = ""
|
||||
|
||||
var serverUrl:String = ""
|
||||
var token:String = ""
|
||||
var playlistUrl:String = ""
|
||||
var title:String = "No Title"
|
||||
var author:String = "Unknown"
|
||||
var series:String = ""
|
||||
var cover:String = ""
|
||||
var playWhenReady:Boolean = false
|
||||
var startTime:Long = 0
|
||||
var duration:Long = 0
|
||||
|
||||
var hasPlayerLoaded:Boolean = false
|
||||
constructor(jsobj: JSObject, serverUrl:String, token:String) {
|
||||
this.serverUrl = serverUrl
|
||||
this.token = token
|
||||
|
||||
val playlistUri:Uri
|
||||
val coverUri:Uri
|
||||
id = jsobj.getString("id", "").toString()
|
||||
ino = jsobj.getString("ino", "").toString()
|
||||
libraryId = jsobj.getString("libraryId", "").toString()
|
||||
folderId = jsobj.getString("folderId", "").toString()
|
||||
|
||||
constructor(jsondata:JSObject) {
|
||||
id = jsondata.getString("id", "audiobook").toString()
|
||||
title = jsondata.getString("title", "No Title").toString()
|
||||
token = jsondata.getString("token", "").toString()
|
||||
author = jsondata.getString("author", "Unknown").toString()
|
||||
series = jsondata.getString("series", "").toString()
|
||||
cover = jsondata.getString("cover", "").toString()
|
||||
playlistUrl = jsondata.getString("playlistUrl", "").toString()
|
||||
playWhenReady = jsondata.getBoolean("playWhenReady", false) == true
|
||||
startTime = jsondata.getString("startTime", "0")!!.toLong()
|
||||
duration = jsondata.getString("duration", "0")!!.toLong()
|
||||
var bookJsObj = jsobj.getJSObject("book")
|
||||
book = bookJsObj?.let { Book(it) }!!
|
||||
|
||||
playlistUri = Uri.parse(playlistUrl)
|
||||
coverUri = Uri.parse(cover)
|
||||
duration = jsobj.getDouble("duration").toFloat()
|
||||
size = jsobj.getLong("size")
|
||||
numTracks = jsobj.getInteger("numTracks")!!
|
||||
isMissing = jsobj.getBoolean("isMissing")
|
||||
isInvalid = jsobj.getBoolean("isInvalid")
|
||||
path = jsobj.getString("path", "").toString()
|
||||
|
||||
isDownloaded = jsobj.getBoolean("isDownloaded")
|
||||
if (isDownloaded) {
|
||||
downloadFolderUrl = jsobj.getString("downloadFolderUrl", "").toString()
|
||||
folderUrl = jsobj.getString("folderUrl", "").toString()
|
||||
contentUrl = jsobj.getString("contentUrl", "").toString()
|
||||
filename = jsobj.getString("filename", "").toString()
|
||||
localCover = jsobj.getString("localCover", "").toString()
|
||||
localCoverUrl = jsobj.getString("localCoverUrl", "").toString()
|
||||
}
|
||||
}
|
||||
|
||||
fun getCover():Uri {
|
||||
if (isDownloaded) {
|
||||
// return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return Uri.parse(localCoverUrl)
|
||||
}
|
||||
if (book.cover == "" || serverUrl == "" || token == "") return Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
return Uri.parse("$serverUrl${book.cover}?token=$token&ts=${book.lastUpdate}")
|
||||
}
|
||||
|
||||
fun getDurationLong():Long {
|
||||
return duration.toLong() * 1000L
|
||||
}
|
||||
|
||||
fun toMediaMetadata():MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, book.title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, book.title)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, book.authorFL)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI, getCover().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getCover().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ART_URI, getCover().toString())
|
||||
putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, book.authorFL)
|
||||
|
||||
// val extras = Bundle()
|
||||
// if (isDownloaded) {
|
||||
// extras.putLong(
|
||||
// MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||
// MediaDescriptionCompat.STATUS_DOWNLOADED)
|
||||
// }
|
||||
// extras.putInt(
|
||||
// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||
// MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED)
|
||||
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, RESOURCE_ROOT_URI +
|
||||
// context.resources.getResourceEntryName(R.drawable.notification_bg_low_normal))
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSArray
|
||||
import com.getcapacitor.JSObject
|
||||
import com.jeep.plugin.capacitor.capacitordatastoragesqlite.CapacitorDataStorageSqlite
|
||||
import okhttp3.*
|
||||
import org.json.JSONArray
|
||||
import java.io.IOException
|
||||
|
||||
class AudiobookManager {
|
||||
var tag = "AudiobookManager"
|
||||
|
||||
interface OnStreamData {
|
||||
fun onStreamReady(asd:AudiobookStreamData)
|
||||
}
|
||||
|
||||
var hasLoaded = false
|
||||
var isLoading = false
|
||||
var ctx: Context
|
||||
var serverUrl = ""
|
||||
var token = ""
|
||||
private var client:OkHttpClient
|
||||
|
||||
var localMediaManager:LocalMediaManager
|
||||
|
||||
var audiobooks:MutableList<Audiobook> = mutableListOf()
|
||||
var audiobooksInProgress:MutableList<Audiobook> = mutableListOf()
|
||||
|
||||
var storageSharedPreferences: SharedPreferences? = null
|
||||
|
||||
constructor(_ctx:Context, _client:OkHttpClient) {
|
||||
ctx = _ctx
|
||||
client = _client
|
||||
|
||||
localMediaManager = LocalMediaManager(ctx)
|
||||
}
|
||||
|
||||
fun init() {
|
||||
storageSharedPreferences = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE)
|
||||
serverUrl = storageSharedPreferences?.getString("serverUrl", "").toString()
|
||||
Log.d(tag, "SHARED PREF SERVERURL $serverUrl")
|
||||
token = storageSharedPreferences?.getString("token", "").toString()
|
||||
Log.d(tag, "SHARED PREF TOKEN $token")
|
||||
}
|
||||
|
||||
fun getPlaybackRate() : Float {
|
||||
if (storageSharedPreferences != null) {
|
||||
var userSettings = storageSharedPreferences?.getString("userSettings", "").toString()
|
||||
if (userSettings != "") {
|
||||
var json = JSObject(userSettings)
|
||||
var playbackRate = json.getString("playbackRate", "1")
|
||||
if (playbackRate != null) {
|
||||
return playbackRate.toFloat()
|
||||
}
|
||||
}
|
||||
}
|
||||
return 1f
|
||||
}
|
||||
|
||||
fun loadCategories(cb: (() -> Unit)) {
|
||||
Log.d(tag, "LOAD Categories $serverUrl | $token")
|
||||
var url = "$serverUrl/api/libraries/main/categories"
|
||||
val request = Request.Builder()
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
cb()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
|
||||
var bodyString = response.body!!.string()
|
||||
var results = JSONArray(bodyString)
|
||||
// var results = resJson.getJSONArray("results")
|
||||
|
||||
var totalShelves = results.length() - 1
|
||||
Log.d(tag, "Got categories $totalShelves")
|
||||
for (i in 0..totalShelves) {
|
||||
var shelfobj = results.get(i)
|
||||
var jsobj = JSObject(shelfobj.toString())
|
||||
var shelfId = jsobj.getString("id", "")
|
||||
Log.d(tag, "Category shelf id $shelfId")
|
||||
if (shelfId == "continue-reading") {
|
||||
var entities = jsobj.getJSONArray("entities")
|
||||
var totalEntities = entities.length() - 1
|
||||
Log.d(tag, "Shelf total entities $totalEntities")
|
||||
for (y in 0..totalEntities) {
|
||||
var abobj = entities.get(y)
|
||||
Log.d(tag, "Shelf category ab id $y = ${abobj.toString()}")
|
||||
var abjsobj = JSObject(abobj.toString())
|
||||
abjsobj.put("isDownloaded", false)
|
||||
var audiobook = Audiobook(abjsobj, serverUrl, token)
|
||||
if (audiobook.isMissing || audiobook.isInvalid || audiobook.numTracks <= 0) {
|
||||
Log.d(tag, "Not an audiobook or invalid/missing")
|
||||
} else {
|
||||
var audiobookExists = audiobooksInProgress.find { it.id == audiobook.id }
|
||||
if (audiobookExists == null) {
|
||||
audiobooksInProgress.add(audiobook)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(tag, "${audiobooksInProgress.size} Audiobooks In Progress Loaded")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun loadAudiobooks(cb: (() -> Unit)) {
|
||||
Log.d(tag, "Load Audiobooks: $serverUrl | $token")
|
||||
if (serverUrl == "" || token == "") {
|
||||
Log.d(tag, "Load Audiobooks: No Server or Token set")
|
||||
cb()
|
||||
return
|
||||
} else if (!serverUrl.startsWith("http")) {
|
||||
Log.e(tag, "Load Audiobooks: Invalid server url $serverUrl")
|
||||
cb()
|
||||
return
|
||||
}
|
||||
|
||||
// First load currently reading
|
||||
loadCategories() {
|
||||
// Then load all
|
||||
var url = "$serverUrl/api/libraries/main/books/all?sort=book.title"
|
||||
val request = Request.Builder()
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "Load Audiobooks: FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
cb()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
|
||||
var bodyString = response.body!!.string()
|
||||
var resJson = JSObject(bodyString)
|
||||
var results = resJson.getJSONArray("results")
|
||||
|
||||
var totalBooks = results.length() - 1
|
||||
for (i in 0..totalBooks) {
|
||||
var abobj = results.get(i)
|
||||
var jsobj = JSObject(abobj.toString())
|
||||
|
||||
jsobj.put("isDownloaded", false)
|
||||
var audiobook = Audiobook(jsobj, serverUrl, token)
|
||||
|
||||
if (audiobook.isMissing || audiobook.isInvalid) {
|
||||
Log.d(tag, "Audiobook ${audiobook.book.title} is missing or invalid")
|
||||
} else if (audiobook.numTracks <= 0) {
|
||||
Log.d(tag, "Audiobook ${audiobook.book.title} has audio tracks")
|
||||
} else {
|
||||
var audiobookExists = audiobooks.find { it.id == audiobook.id }
|
||||
if (audiobookExists == null) {
|
||||
audiobooks.add(audiobook)
|
||||
} else {
|
||||
Log.d(tag, "Audiobook already there from downloaded")
|
||||
}
|
||||
}
|
||||
}
|
||||
Log.d(tag, "${audiobooks.size} Audiobooks Loaded")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fun load() {
|
||||
isLoading = true
|
||||
hasLoaded = true
|
||||
|
||||
localMediaManager.loadLocalAudio()
|
||||
|
||||
// Load downloads from sql db
|
||||
var db = CapacitorDataStorageSqlite(ctx)
|
||||
db.openStore("storage", "downloads", false, "no-encryption", 1)
|
||||
var keyvalues = db.keysvalues()
|
||||
keyvalues.forEach {
|
||||
Log.d(tag, "keyvalue ${it.getString("key")} | ${it.getString("value")}")
|
||||
|
||||
var dlobj = JSObject(it.getString("value"))
|
||||
if (dlobj.has("audiobook")) {
|
||||
var abobj = dlobj.getJSObject("audiobook")!!
|
||||
abobj.put("isDownloaded", true)
|
||||
abobj.put("contentUrl", dlobj.getString("contentUrl", "").toString())
|
||||
abobj.put("filename", dlobj.getString("filename", "").toString())
|
||||
abobj.put("folderUrl", dlobj.getString("folderUrl", "").toString())
|
||||
abobj.put("downloadFolderUrl", dlobj.getString("downloadFolderUrl", "").toString())
|
||||
abobj.put("localCoverUrl", dlobj.getString("coverUrl", "").toString())
|
||||
abobj.put("localCover", dlobj.getString("cover", "").toString())
|
||||
|
||||
var audiobook = Audiobook(abobj, serverUrl, token)
|
||||
audiobooks.add(audiobook)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun openStream(audiobook:Audiobook, streamListener:OnStreamData) {
|
||||
var url = "$serverUrl/api/books/${audiobook.id}/stream"
|
||||
val request = Request.Builder()
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
|
||||
var playbackRate = getPlaybackRate()
|
||||
|
||||
var bodyString = response.body!!.string()
|
||||
var stream = JSObject(bodyString)
|
||||
var streamId = stream.getString("streamId", "").toString()
|
||||
var startTime = stream.getDouble("startTime")
|
||||
var streamUrl = stream.getString("streamUrl", "").toString()
|
||||
|
||||
var startTimeLong = (startTime * 1000).toLong()
|
||||
|
||||
var abStreamDataObj = JSObject()
|
||||
abStreamDataObj.put("id", streamId)
|
||||
abStreamDataObj.put("audiobookId", audiobook.id)
|
||||
abStreamDataObj.put("playlistUrl", "$serverUrl$streamUrl")
|
||||
abStreamDataObj.put("title", audiobook.book.title)
|
||||
abStreamDataObj.put("author", audiobook.book.authorFL)
|
||||
abStreamDataObj.put("token", token)
|
||||
abStreamDataObj.put("cover", audiobook.getCover())
|
||||
abStreamDataObj.put("duration", audiobook.getDurationLong())
|
||||
abStreamDataObj.put("startTime", startTimeLong)
|
||||
abStreamDataObj.put("playbackSpeed", playbackRate)
|
||||
abStreamDataObj.put("playWhenReady", true)
|
||||
abStreamDataObj.put("isLocal", false)
|
||||
|
||||
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
Log.d(tag, "Stream Ready on Main Looper")
|
||||
streamListener.onStreamReady(audiobookStreamData)
|
||||
}
|
||||
|
||||
Log.d(tag, "Init Player Stream")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fun initDownloadPlay(audiobook:Audiobook):AudiobookStreamData {
|
||||
var playbackRate = getPlaybackRate()
|
||||
|
||||
var abStreamDataObj = JSObject()
|
||||
abStreamDataObj.put("id", "download")
|
||||
abStreamDataObj.put("audiobookId", audiobook.id)
|
||||
abStreamDataObj.put("contentUrl", audiobook.contentUrl)
|
||||
abStreamDataObj.put("title", audiobook.book.title)
|
||||
abStreamDataObj.put("author", audiobook.book.authorFL)
|
||||
abStreamDataObj.put("token", null)
|
||||
abStreamDataObj.put("cover", audiobook.getCover())
|
||||
abStreamDataObj.put("duration", audiobook.getDurationLong())
|
||||
abStreamDataObj.put("startTime", 0)
|
||||
abStreamDataObj.put("playbackSpeed", playbackRate)
|
||||
abStreamDataObj.put("playWhenReady", true)
|
||||
abStreamDataObj.put("isLocal", true)
|
||||
|
||||
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
|
||||
return audiobookStreamData
|
||||
}
|
||||
|
||||
fun initLocalPlay(local: LocalMediaManager.LocalAudio):AudiobookStreamData {
|
||||
var abStreamDataObj = JSObject()
|
||||
abStreamDataObj.put("id", "local")
|
||||
abStreamDataObj.put("audiobookId", local.id)
|
||||
abStreamDataObj.put("contentUrl", local.uri.toString())
|
||||
abStreamDataObj.put("title", local.name)
|
||||
abStreamDataObj.put("author", "")
|
||||
abStreamDataObj.put("token", null)
|
||||
abStreamDataObj.put("cover", local.coverUri)
|
||||
abStreamDataObj.put("duration", local.duration)
|
||||
abStreamDataObj.put("startTime", 0)
|
||||
abStreamDataObj.put("playbackSpeed", 1)
|
||||
abStreamDataObj.put("playWhenReady", true)
|
||||
abStreamDataObj.put("isLocal", true)
|
||||
|
||||
var audiobookStreamData = AudiobookStreamData(abStreamDataObj)
|
||||
return audiobookStreamData
|
||||
}
|
||||
|
||||
private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int {
|
||||
val lhsLength = lhs.length + 1
|
||||
val rhsLength = rhs.length + 1
|
||||
|
||||
var cost = Array(lhsLength) { it }
|
||||
var newCost = Array(lhsLength) { 0 }
|
||||
|
||||
for (i in 1..rhsLength-1) {
|
||||
newCost[0] = i
|
||||
|
||||
for (j in 1..lhsLength-1) {
|
||||
val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1
|
||||
|
||||
val costReplace = cost[j - 1] + match
|
||||
val costInsert = cost[j] + 1
|
||||
val costDelete = newCost[j - 1] + 1
|
||||
|
||||
newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace)
|
||||
}
|
||||
|
||||
val swap = cost
|
||||
cost = newCost
|
||||
newCost = swap
|
||||
}
|
||||
|
||||
return cost[lhsLength - 1]
|
||||
}
|
||||
|
||||
fun searchForAudiobook(query:String):Audiobook? {
|
||||
var closestDistance = 99
|
||||
var closestMatch:Audiobook? = null
|
||||
audiobooks.forEach {
|
||||
var dist = levenshtein(it.book.title, query)
|
||||
Log.d(tag, "LEVENSHTEIN $dist")
|
||||
if (dist < closestDistance) {
|
||||
closestDistance = dist
|
||||
closestMatch = it
|
||||
}
|
||||
}
|
||||
if (closestMatch != null) {
|
||||
Log.d(tag, "Closest Search is ${closestMatch?.book?.title} with distance $closestDistance")
|
||||
if (closestDistance < 2) {
|
||||
return closestMatch
|
||||
}
|
||||
return null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
fun getFirstAudiobook():Audiobook? {
|
||||
if (audiobooks.isEmpty()) return null
|
||||
return audiobooks[0]
|
||||
}
|
||||
|
||||
fun getFirstLocal(): LocalMediaManager.LocalAudio? {
|
||||
if (localMediaManager.localAudioFiles.isEmpty()) return null
|
||||
return localMediaManager.localAudioFiles[0]
|
||||
}
|
||||
|
||||
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
|
||||
fun getAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
|
||||
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
|
||||
if (audiobooks.isEmpty()) {
|
||||
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
|
||||
} else {
|
||||
audiobooks.forEach { mediaMetadata.add(it.toMediaMetadata()) }
|
||||
}
|
||||
return mediaMetadata
|
||||
}
|
||||
// Used for media browser loadChildren, fallback to using the samples if no audiobooks are there
|
||||
fun getDownloadedAudiobooksMediaMetadata() : List<MediaMetadataCompat> {
|
||||
var mediaMetadata:MutableList<MediaMetadataCompat> = mutableListOf()
|
||||
if (audiobooks.isEmpty()) {
|
||||
localMediaManager.localAudioFiles.forEach { mediaMetadata.add(it.toMediaMetadata()) }
|
||||
} else {
|
||||
audiobooks.forEach { if (it.isDownloaded) { mediaMetadata.add(it.toMediaMetadata()) } }
|
||||
}
|
||||
return mediaMetadata
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSObject
|
||||
import okhttp3.*
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import java.io.IOException
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
|
||||
/*
|
||||
* Normal progress sync is handled in webview, but when using android auto webview may not be open.
|
||||
* If webview is not open sync progress every 5s. Webview can be closed at any time so interval is always set.
|
||||
*/
|
||||
class AudiobookProgressSyncer constructor(playerNotificationService:PlayerNotificationService, client: OkHttpClient) {
|
||||
private val tag = "AudiobookProgressSync"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
private val client:OkHttpClient = client
|
||||
|
||||
private var listeningTimerTask: TimerTask? = null
|
||||
var listeningTimerRunning:Boolean = false
|
||||
|
||||
private var webviewOpenOnStart:Boolean = false
|
||||
private var webviewClosedMidSession:Boolean = false
|
||||
private var listeningBookTitle:String? = ""
|
||||
private var listeningBookIsLocal:Boolean = false
|
||||
private var listeningBookId:String? = ""
|
||||
private var listeningStreamId:String? = ""
|
||||
|
||||
private var lastPlaybackTime:Long = 0
|
||||
private var lastUpdateTime:Long = 0
|
||||
|
||||
fun start() {
|
||||
if (listeningTimerRunning) {
|
||||
Log.d(tag, "start: Timer already running for $listeningBookTitle")
|
||||
if (playerNotificationService.getCurrentBookTitle() != listeningBookTitle) {
|
||||
Log.d(tag, "start: Changed audiobook stream - resetting timer")
|
||||
listeningTimerTask?.cancel()
|
||||
}
|
||||
}
|
||||
listeningTimerRunning = true
|
||||
|
||||
webviewOpenOnStart = playerNotificationService.getIsWebviewOpen()
|
||||
listeningBookTitle = playerNotificationService.getCurrentBookTitle()
|
||||
listeningBookIsLocal = playerNotificationService.getCurrentBookIsLocal()
|
||||
listeningBookId = playerNotificationService.getCurrentBookId()
|
||||
listeningStreamId = playerNotificationService.getCurrentStreamId()
|
||||
|
||||
lastPlaybackTime = playerNotificationService.getCurrentTime()
|
||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
||||
|
||||
listeningTimerTask = Timer("ListeningTimer", false).schedule(0L, 5000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
// Webview was closed while android auto is open - switch to native sync
|
||||
var isWebviewOpen = playerNotificationService.getIsWebviewOpen()
|
||||
if (!isWebviewOpen && webviewOpenOnStart) {
|
||||
Log.d(tag, "Listening Timer: webview closed Switching to native sync tracking")
|
||||
webviewOpenOnStart = false
|
||||
webviewClosedMidSession = true
|
||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
||||
} else if (isWebviewOpen && webviewClosedMidSession) {
|
||||
Log.d(tag, "Listening Timer: webview re-opened Switching back to webview sync tracking")
|
||||
webviewClosedMidSession = false
|
||||
webviewOpenOnStart = true
|
||||
lastUpdateTime = System.currentTimeMillis() / 1000L
|
||||
}
|
||||
if (!webviewOpenOnStart && playerNotificationService.currentPlayer.isPlaying) {
|
||||
sync()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (!listeningTimerRunning) return
|
||||
Log.d(tag, "stop: Stopping listening for $listeningBookTitle")
|
||||
|
||||
if (!webviewOpenOnStart) {
|
||||
sync()
|
||||
}
|
||||
reset()
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
listeningTimerTask?.cancel()
|
||||
listeningTimerTask = null
|
||||
listeningTimerRunning = false
|
||||
listeningBookTitle = ""
|
||||
listeningBookId = ""
|
||||
listeningBookIsLocal = false
|
||||
listeningStreamId = ""
|
||||
}
|
||||
|
||||
fun sync() {
|
||||
var currTime = System.currentTimeMillis() / 1000L
|
||||
var elapsed = currTime - lastUpdateTime
|
||||
lastUpdateTime = currTime
|
||||
|
||||
if (!listeningBookIsLocal) {
|
||||
Log.d(tag, "ListeningTimer: Sending sync data to server: elapsed $elapsed | $listeningStreamId | $listeningBookId")
|
||||
|
||||
// Send sync data only for streaming books
|
||||
var syncData: JSObject = JSObject()
|
||||
syncData.put("timeListened", elapsed)
|
||||
syncData.put("currentTime", playerNotificationService.getCurrentTime() / 1000)
|
||||
syncData.put("streamId", listeningStreamId)
|
||||
syncData.put("audiobookId", listeningBookId)
|
||||
sendStreamSyncData(syncData) {
|
||||
Log.d(tag, "Stream sync done")
|
||||
}
|
||||
} else if (listeningStreamId == "download") {
|
||||
// TODO: Save downloaded audiobook progress & send to server if connected
|
||||
Log.d(tag, "ListeningTimer: Is listening download")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendStreamSyncData(payload:JSObject, cb: (() -> Unit)) {
|
||||
var serverUrl = playerNotificationService.getServerUrl()
|
||||
var token = playerNotificationService.getUserToken()
|
||||
|
||||
if (serverUrl == "" || token == "") {
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(tag, "Sync Stream $serverUrl | $token")
|
||||
var url = "$serverUrl/api/syncStream"
|
||||
|
||||
val mediaType = "application/json; charset=utf-8".toMediaType()
|
||||
val requestBody = payload.toString().toRequestBody(mediaType)
|
||||
val request = Request.Builder().post(requestBody)
|
||||
.url(url).addHeader("Authorization", "Bearer $token")
|
||||
.build()
|
||||
|
||||
client.newCall(request).enqueue(object : Callback {
|
||||
override fun onFailure(call: Call, e: IOException) {
|
||||
Log.d(tag, "FAILURE TO CONNECT")
|
||||
e.printStackTrace()
|
||||
cb()
|
||||
}
|
||||
|
||||
override fun onResponse(call: Call, response: Response) {
|
||||
response.use {
|
||||
if (!response.isSuccessful) throw IOException("Unexpected code $response")
|
||||
cb()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import com.getcapacitor.JSObject
|
||||
import com.google.android.exoplayer2.MediaItem
|
||||
import com.google.android.exoplayer2.MediaMetadata
|
||||
import com.google.android.exoplayer2.util.MimeTypes
|
||||
import java.lang.Exception
|
||||
|
||||
class AudiobookStreamData {
|
||||
var id:String = "unset"
|
||||
var audiobookId:String = ""
|
||||
var token:String = ""
|
||||
var playlistUrl:String = ""
|
||||
var title:String = "No Title"
|
||||
var author:String = "Unknown"
|
||||
var series:String = ""
|
||||
var cover:String = ""
|
||||
var playWhenReady:Boolean = false
|
||||
var startTime:Long = 0
|
||||
var playbackSpeed:Float = 1f
|
||||
var duration:Long = 0
|
||||
var tracks:MutableList<String> = mutableListOf()
|
||||
|
||||
var isLocal:Boolean = false
|
||||
var contentUrl:String = ""
|
||||
|
||||
var hasPlayerLoaded:Boolean = false
|
||||
|
||||
var playlistUri:Uri = Uri.EMPTY
|
||||
var coverUri:Uri = Uri.EMPTY
|
||||
var contentUri:Uri = Uri.EMPTY // For Local only
|
||||
|
||||
constructor(jsondata:JSObject) {
|
||||
id = jsondata.getString("id", "unset").toString()
|
||||
audiobookId = jsondata.getString("audiobookId", "").toString()
|
||||
title = jsondata.getString("title", "No Title").toString()
|
||||
token = jsondata.getString("token", "").toString()
|
||||
author = jsondata.getString("author", "Unknown").toString()
|
||||
series = jsondata.getString("series", "").toString()
|
||||
cover = jsondata.getString("cover", "").toString()
|
||||
playlistUrl = jsondata.getString("playlistUrl", "").toString()
|
||||
playWhenReady = jsondata.getBoolean("playWhenReady", false) == true
|
||||
|
||||
if (jsondata.has("startTime")) {
|
||||
startTime = jsondata.getString("startTime", "0")!!.toLong()
|
||||
}
|
||||
|
||||
if (jsondata.has("duration")) {
|
||||
duration = jsondata.getString("duration", "0")!!.toLong()
|
||||
}
|
||||
|
||||
if (jsondata.has("playbackSpeed")) {
|
||||
playbackSpeed = jsondata.getDouble("playbackSpeed")!!.toFloat()
|
||||
}
|
||||
|
||||
|
||||
// Local data
|
||||
isLocal = jsondata.getBoolean("isLocal", false) == true
|
||||
contentUrl = jsondata.getString("contentUrl", "").toString()
|
||||
|
||||
if (playlistUrl != "") {
|
||||
playlistUri = Uri.parse(playlistUrl)
|
||||
}
|
||||
if (cover != "" && cover != null) {
|
||||
coverUri = Uri.parse(cover)
|
||||
} else {
|
||||
coverUri = Uri.parse("android.resource://com.audiobookshelf.app/" + R.drawable.icon)
|
||||
cover = coverUri.toString()
|
||||
}
|
||||
|
||||
if (contentUrl != "") {
|
||||
contentUri = Uri.parse(contentUrl)
|
||||
}
|
||||
|
||||
// Tracks for cast
|
||||
try {
|
||||
var tracksTest = jsondata.getJSONArray("tracks")
|
||||
Log.d("AudiobookStreamData", "Load tracks from json array ${tracksTest.length()}")
|
||||
for (i in 0 until tracksTest.length()) {
|
||||
var track = tracksTest.get(i)
|
||||
Log.d("AudiobookStreamData", "Extracting track $track")
|
||||
tracks.add(track as String)
|
||||
}
|
||||
} catch(e:Exception) {
|
||||
Log.d("AudiobookStreamData", "No tracks found $e")
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCover() {
|
||||
coverUri = Uri.EMPTY
|
||||
cover = ""
|
||||
}
|
||||
|
||||
fun getMediaMetadataCompat():MediaMetadataCompat {
|
||||
var metadataBuilder = MediaMetadataCompat.Builder()
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, title)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, title)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, author)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, series)
|
||||
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
|
||||
// if (cover != "") {
|
||||
// metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, cover)
|
||||
// metadataBuilder.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, cover)
|
||||
// }
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
fun getMediaMetadata():MediaMetadata {
|
||||
var metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setDisplayTitle(title)
|
||||
.setArtist(author)
|
||||
.setAlbumArtist(author)
|
||||
.setSubtitle(author)
|
||||
|
||||
// if (coverUri != Uri.EMPTY) {
|
||||
// metadataBuilder.setArtworkUri(coverUri)
|
||||
// }
|
||||
if (playlistUri != Uri.EMPTY) {
|
||||
metadataBuilder.setMediaUri(playlistUri)
|
||||
}
|
||||
if (contentUri != Uri.EMPTY) {
|
||||
metadataBuilder.setMediaUri(contentUri)
|
||||
}
|
||||
return metadataBuilder.build()
|
||||
}
|
||||
|
||||
fun getMimeType():String {
|
||||
return if (isLocal) {
|
||||
MimeTypes.BASE_TYPE_AUDIO
|
||||
} else {
|
||||
MimeTypes.APPLICATION_M3U8
|
||||
}
|
||||
}
|
||||
|
||||
fun getMediaUri():Uri {
|
||||
return if (isLocal) {
|
||||
contentUri
|
||||
} else {
|
||||
Uri.parse("$playlistUrl?token=$token")
|
||||
}
|
||||
}
|
||||
|
||||
fun getCastQueue():ArrayList<MediaItem> {
|
||||
var mediaQueue: java.util.ArrayList<MediaItem> = java.util.ArrayList<MediaItem>()
|
||||
|
||||
for (i in 0 until tracks.size) {
|
||||
var track = tracks[i]
|
||||
var metadataBuilder = MediaMetadata.Builder()
|
||||
.setTitle(title)
|
||||
.setDisplayTitle(title)
|
||||
.setArtist(author)
|
||||
.setAlbumArtist(author)
|
||||
.setSubtitle(author)
|
||||
.setTrackNumber(i + 1)
|
||||
|
||||
if (coverUri != Uri.EMPTY) {
|
||||
metadataBuilder.setArtworkUri(coverUri)
|
||||
}
|
||||
|
||||
var mimeType = MimeTypes.BASE_TYPE_AUDIO
|
||||
|
||||
var mediaMetadata = metadataBuilder.build()
|
||||
var mediaItem = MediaItem.Builder().setUri(Uri.parse(track)).setMediaMetadata(mediaMetadata).setMimeType(mimeType).build()
|
||||
mediaQueue.add(mediaItem)
|
||||
}
|
||||
|
||||
return mediaQueue
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import com.getcapacitor.JSObject
|
||||
|
||||
class Book {
|
||||
var title:String
|
||||
var subtitle:String
|
||||
var author:String
|
||||
var authorFL:String
|
||||
var narrator:String
|
||||
var series:String
|
||||
var volumeNumber:String
|
||||
var publisher:String
|
||||
var description:String
|
||||
var publishYear:String
|
||||
var language:String
|
||||
var cover:String
|
||||
var coverFullPath:String
|
||||
var genres:String
|
||||
var lastUpdate:Long
|
||||
|
||||
constructor(jsobj: JSObject) {
|
||||
title = jsobj.getString("title", "").toString()
|
||||
subtitle = jsobj.getString("subtitle", "").toString()
|
||||
author = jsobj.getString("author", "").toString()
|
||||
authorFL = jsobj.getString("authorFL", "").toString()
|
||||
narrator = jsobj.getString("narrator", "").toString()
|
||||
series = jsobj.getString("series", "").toString()
|
||||
volumeNumber = jsobj.getString("volumeNumber", "").toString()
|
||||
publisher = jsobj.getString("publisher", "").toString()
|
||||
description = jsobj.getString("description", "").toString()
|
||||
publishYear = jsobj.getString("publishYear", "").toString()
|
||||
language = jsobj.getString("language", "").toString()
|
||||
cover = jsobj.getString("cover", "").toString()
|
||||
coverFullPath = jsobj.getString("coverFullPath", "").toString()
|
||||
genres = jsobj.getString("genres", "").toString()
|
||||
lastUpdate = jsobj.getLong("lastUpdate")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.content.ContentResolver
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.AnyRes
|
||||
|
||||
|
||||
class BrowseTree(
|
||||
val context: Context,
|
||||
audiobooksInProgress: List<Audiobook>,
|
||||
audiobookMetadata: List<MediaMetadataCompat>,
|
||||
downloadedMetadata: List<MediaMetadataCompat>
|
||||
) {
|
||||
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
||||
|
||||
/**
|
||||
* get uri to drawable or any other resource type if u wish
|
||||
* @param context - context
|
||||
* @param drawableId - drawable res id
|
||||
* @return - uri
|
||||
*/
|
||||
fun getUriToDrawable(context: Context,
|
||||
@AnyRes drawableId: Int): Uri {
|
||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||
+ '/' + context.resources.getResourceEntryName(drawableId))
|
||||
}
|
||||
|
||||
init {
|
||||
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
||||
|
||||
|
||||
val continueReadingMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Reading")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
||||
}.build()
|
||||
|
||||
val allMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, ALL_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Audiobooks")
|
||||
|
||||
var resource = getUriToDrawable(context, R.drawable.exo_icon_books).toString()
|
||||
Log.d("BrowseTree", "RESOURCE $resource")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, resource)
|
||||
}.build()
|
||||
|
||||
|
||||
val downloadsMetadata = MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, DOWNLOADS_ROOT)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Downloads")
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_downloaddone).toString())
|
||||
}.build()
|
||||
|
||||
// val localsMetadata = MediaMetadataCompat.Builder().apply {
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LOCAL_ROOT)
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Samples")
|
||||
// putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
||||
// }.build()
|
||||
|
||||
if (audiobooksInProgress.isNotEmpty()) {
|
||||
rootList += continueReadingMetadata
|
||||
}
|
||||
rootList += allMetadata
|
||||
rootList += downloadsMetadata
|
||||
// rootList += localsMetadata
|
||||
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
||||
|
||||
audiobooksInProgress.forEach { audiobook ->
|
||||
val children = mediaIdToChildren[CONTINUE_ROOT] ?: mutableListOf()
|
||||
children += audiobook.toMediaMetadata()
|
||||
mediaIdToChildren[CONTINUE_ROOT] = children
|
||||
}
|
||||
|
||||
audiobookMetadata.forEach {
|
||||
val allChildren = mediaIdToChildren[ALL_ROOT] ?: mutableListOf()
|
||||
allChildren += it
|
||||
mediaIdToChildren[ALL_ROOT] = allChildren
|
||||
}
|
||||
|
||||
downloadedMetadata.forEach {
|
||||
val allChildren = mediaIdToChildren[DOWNLOADS_ROOT] ?: mutableListOf()
|
||||
allChildren += it
|
||||
mediaIdToChildren[DOWNLOADS_ROOT] = allChildren
|
||||
}
|
||||
|
||||
// localAudio.forEach { local ->
|
||||
// val localChildren = mediaIdToChildren[LOCAL_ROOT] ?: mutableListOf()
|
||||
// localChildren += local.toMediaMetadata()
|
||||
// mediaIdToChildren[LOCAL_ROOT] = localChildren
|
||||
// }
|
||||
// Log.d("BrowseTree", "Set LOCAL AUDIO ${localAudio.size}")
|
||||
}
|
||||
|
||||
operator fun get(mediaId: String) = mediaIdToChildren[mediaId]
|
||||
}
|
||||
|
||||
const val AUTO_BROWSE_ROOT = "/"
|
||||
const val ALL_ROOT = "__ALL__"
|
||||
const val CONTINUE_ROOT = "__CONTINUE__"
|
||||
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||
//const val LOCAL_ROOT = "__LOCAL__"
|
||||
@@ -0,0 +1,379 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import androidx.appcompat.R
|
||||
import androidx.mediarouter.app.MediaRouteChooserDialog
|
||||
import androidx.mediarouter.media.MediaRouteSelector
|
||||
import androidx.mediarouter.media.MediaRouter
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.google.android.exoplayer2.ext.cast.CastPlayer
|
||||
import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener
|
||||
import com.google.android.gms.cast.Cast
|
||||
import com.google.android.gms.cast.CastDevice
|
||||
import com.google.android.gms.cast.CastMediaControlIntent
|
||||
import com.google.android.gms.cast.framework.*
|
||||
import org.json.JSONObject
|
||||
import java.util.ArrayList
|
||||
|
||||
class CastManager constructor(playerNotificationService:PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
|
||||
private var newConnectionListener:SessionListener? = null
|
||||
private var mainActivity:Activity? = null
|
||||
|
||||
private fun switchToPlayer(useCastPlayer:Boolean) {
|
||||
playerNotificationService.switchToPlayer(useCastPlayer)
|
||||
}
|
||||
|
||||
private inner class CastSessionAvailabilityListener : SessionAvailabilityListener {
|
||||
|
||||
/**
|
||||
* Called when a Cast session has started and the user wishes to control playback on a
|
||||
* remote Cast receiver rather than play audio locally.
|
||||
*/
|
||||
override fun onCastSessionAvailable() {
|
||||
switchToPlayer(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a Cast session has ended and the user wishes to control playback locally.
|
||||
*/
|
||||
override fun onCastSessionUnavailable() {
|
||||
Log.d(tag, "onCastSessionUnavailable")
|
||||
switchToPlayer(false)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSession(mainActivity: Activity, callback: RequestSessionCallback) {
|
||||
this.mainActivity = mainActivity
|
||||
|
||||
mainActivity.runOnUiThread(object : Runnable {
|
||||
override fun run() {
|
||||
Log.d(tag, "CAST RUNNING ON MAIN THREAD")
|
||||
|
||||
val session: CastSession? = getSession()
|
||||
if (session == null) {
|
||||
// show the "choose a connection" dialog
|
||||
|
||||
// Add the connection listener callback
|
||||
listenForConnection(callback)
|
||||
|
||||
val builder = MediaRouteChooserDialog(mainActivity, R.style.Theme_AppCompat_NoActionBar)
|
||||
builder.routeSelector = MediaRouteSelector.Builder()
|
||||
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
||||
.build()
|
||||
builder.setCanceledOnTouchOutside(true)
|
||||
builder.setOnCancelListener {
|
||||
getSessionManager()!!.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
callback.onCancel()
|
||||
}
|
||||
builder.show()
|
||||
} else {
|
||||
// We are are already connected, so show the "connection options" Dialog
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity)
|
||||
if (session.castDevice != null) {
|
||||
builder.setTitle(session.castDevice.friendlyName)
|
||||
}
|
||||
builder.setOnDismissListener { callback.onCancel() }
|
||||
builder.setPositiveButton("Stop Casting") { dialog, which -> endSession(true, null) }
|
||||
builder.show()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
abstract class RequestSessionCallback : ConnectionCallback {
|
||||
abstract fun onError(errorCode: Int)
|
||||
abstract fun onCancel()
|
||||
override fun onSessionEndedBeforeStart(errorCode: Int): Boolean {
|
||||
onSessionStartFailed(errorCode)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onSessionStartFailed(errorCode: Int): Boolean {
|
||||
onError(errorCode)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
fun endSession(stopCasting: Boolean, pluginCall: PluginCall?) {
|
||||
|
||||
getSessionManager()!!.addSessionManagerListener(object : SessionListener() {
|
||||
override fun onSessionEnded(castSession: CastSession?, error: Int) {
|
||||
getSessionManager()!!.removeSessionManagerListener(this, CastSession::class.java)
|
||||
Log.d(tag, "CAST END SESSION")
|
||||
// media.setSession(null)
|
||||
pluginCall?.resolve()
|
||||
// listener.onSessionEnd(ChromecastUtilities.createSessionObject(castSession, if (stopCasting) "stopped" else "disconnected"))
|
||||
}
|
||||
}, CastSession::class.java)
|
||||
getSessionManager()!!.endCurrentSession(stopCasting)
|
||||
|
||||
}
|
||||
|
||||
open class SessionListener : SessionManagerListener<CastSession> {
|
||||
override fun onSessionStarting(castSession: CastSession?) {}
|
||||
override fun onSessionStarted(castSession: CastSession?, sessionId: String) {}
|
||||
override fun onSessionStartFailed(castSession: CastSession?, error: Int) {}
|
||||
override fun onSessionEnding(castSession: CastSession?) {}
|
||||
override fun onSessionEnded(castSession: CastSession?, error: Int) {}
|
||||
override fun onSessionResuming(castSession: CastSession?, sessionId: String) {}
|
||||
override fun onSessionResumed(castSession: CastSession?, wasSuspended: Boolean) {}
|
||||
override fun onSessionResumeFailed(castSession: CastSession?, error: Int) {}
|
||||
override fun onSessionSuspended(castSession: CastSession?, reason: Int) {}
|
||||
}
|
||||
|
||||
private fun startRouteScan() {
|
||||
var connListener = object: ChromecastListener() {
|
||||
override fun onReceiverAvailableUpdate(available: Boolean) {
|
||||
Log.d(tag, "CAST RECEIVER UPDATE AVAILABLE $available")
|
||||
}
|
||||
|
||||
override fun onSessionRejoin(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionRejoin")
|
||||
}
|
||||
|
||||
override fun onMediaLoaded(jsonMedia: JSONObject?) {
|
||||
Log.d(tag, "CAST onMediaLoaded")
|
||||
}
|
||||
|
||||
override fun onMediaUpdate(jsonMedia: JSONObject?) {
|
||||
Log.d(tag, "CAST onMediaUpdate")
|
||||
}
|
||||
|
||||
override fun onSessionUpdate(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionUpdate")
|
||||
}
|
||||
|
||||
override fun onSessionEnd(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST onSessionEnd")
|
||||
}
|
||||
|
||||
override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) {
|
||||
Log.d(tag, "CAST onMessageReceived")
|
||||
}
|
||||
}
|
||||
|
||||
var callback = object : ScanCallback() {
|
||||
override fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?) {
|
||||
Log.d(tag, "CAST On ROUTE UPDATED ${routes?.size} | ${getContext().castState}")
|
||||
// if the routes have changed, we may have an available device
|
||||
// If there is at least one device available
|
||||
if (getContext().castState != CastState.NO_DEVICES_AVAILABLE) {
|
||||
|
||||
routes?.forEach { Log.d(tag, "CAST ROUTE ${it.description} | ${it.deviceType} | ${it.isBluetooth} | ${it.name}") }
|
||||
|
||||
// Stop the scan
|
||||
stopRouteScan(this, null);
|
||||
// Let the client know a receiver is available
|
||||
connListener.onReceiverAvailableUpdate(true);
|
||||
// Since we have a receiver we may also have an active session
|
||||
var session = getSessionManager()?.currentCastSession;
|
||||
// If we do have a session
|
||||
if (session != null) {
|
||||
// Let the client know
|
||||
Log.d(tag, "LET SESSION KNOW ABOUT")
|
||||
// media.setSession(session);
|
||||
// connListener.onSessionRejoin(ChromecastUtilities.createSessionObject(session));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
callback.setMediaRouter(getMediaRouter())
|
||||
|
||||
callback.onFilteredRouteUpdate();
|
||||
|
||||
getMediaRouter()!!.addCallback(MediaRouteSelector.Builder()
|
||||
.addControlCategory(CastMediaControlIntent.categoryForCast(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID))
|
||||
.build(),
|
||||
callback,
|
||||
MediaRouter.CALLBACK_FLAG_PERFORM_ACTIVE_SCAN)
|
||||
}
|
||||
|
||||
internal interface CastListener : Cast.MessageReceivedCallback {
|
||||
fun onMediaLoaded(jsonMedia: JSONObject?)
|
||||
fun onMediaUpdate(jsonMedia: JSONObject?)
|
||||
fun onSessionUpdate(jsonSession: JSONObject?)
|
||||
fun onSessionEnd(jsonSession: JSONObject?)
|
||||
}
|
||||
|
||||
internal abstract class ChromecastListener : CastStateListener, CastListener {
|
||||
abstract fun onReceiverAvailableUpdate(available: Boolean)
|
||||
abstract fun onSessionRejoin(jsonSession: JSONObject?)
|
||||
|
||||
/** CastStateListener functions. */
|
||||
override fun onCastStateChanged(state: Int) {
|
||||
onReceiverAvailableUpdate(state != CastState.NO_DEVICES_AVAILABLE)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRouteScan(callback: ScanCallback?, completionCallback: Runnable?) {
|
||||
if (callback == null) {
|
||||
completionCallback!!.run()
|
||||
return
|
||||
}
|
||||
// ctx.runOnUiThread(Runnable {
|
||||
callback.stop()
|
||||
getMediaRouter()!!.removeCallback(callback)
|
||||
completionCallback?.run()
|
||||
// })
|
||||
}
|
||||
|
||||
abstract class ScanCallback : MediaRouter.Callback() {
|
||||
/**
|
||||
* Called whenever a route is updated.
|
||||
* @param routes the currently available routes
|
||||
*/
|
||||
abstract fun onRouteUpdate(routes: List<MediaRouter.RouteInfo>?)
|
||||
|
||||
/** records whether we have been stopped or not. */
|
||||
private var stopped = false
|
||||
|
||||
/** Global mediaRouter object. */
|
||||
private var mediaRouter: MediaRouter? = null
|
||||
|
||||
/**
|
||||
* Sets the mediaRouter object.
|
||||
* @param router mediaRouter object
|
||||
*/
|
||||
fun setMediaRouter(router: MediaRouter?) {
|
||||
mediaRouter = router
|
||||
}
|
||||
|
||||
/**
|
||||
* Call this method when you wish to stop scanning.
|
||||
* It is important that it is called, otherwise battery
|
||||
* life will drain more quickly.
|
||||
*/
|
||||
fun stop() {
|
||||
stopped = true
|
||||
}
|
||||
|
||||
fun onFilteredRouteUpdate() {
|
||||
if (stopped || mediaRouter == null) {
|
||||
return
|
||||
}
|
||||
val outRoutes: MutableList<MediaRouter.RouteInfo> = ArrayList()
|
||||
// Filter the routes
|
||||
for (route in mediaRouter!!.routes) {
|
||||
// We don't want default routes, or duplicate active routes
|
||||
// or multizone duplicates https://github.com/jellyfin/cordova-plugin-chromecast/issues/32
|
||||
val extras: Bundle? = route.extras
|
||||
if (extras != null) {
|
||||
CastDevice.getFromBundle(extras)
|
||||
if (extras.getString("com.google.android.gms.cast.EXTRA_SESSION_ID") != null) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if (!route.isDefault
|
||||
&& !route.description.equals("Google Cast Multizone Member")
|
||||
&& route.playbackType === MediaRouter.RouteInfo.PLAYBACK_TYPE_REMOTE) {
|
||||
outRoutes.add(route)
|
||||
}
|
||||
}
|
||||
onRouteUpdate(outRoutes)
|
||||
}
|
||||
|
||||
override fun onRouteAdded(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
|
||||
override fun onRouteChanged(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
|
||||
override fun onRouteRemoved(router: MediaRouter?, route: MediaRouter.RouteInfo?) {
|
||||
onFilteredRouteUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
private fun listenForConnection(callback: ConnectionCallback) {
|
||||
// We should only ever have one of these listeners active at a time, so remove previous
|
||||
getSessionManager()?.removeSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
|
||||
newConnectionListener = object : SessionListener() {
|
||||
override fun onSessionStarted(castSession: CastSession?, sessionId: String) {
|
||||
Log.d(tag, "CAST SESSION STARTED ${castSession?.castDevice?.friendlyName}")
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
|
||||
try {
|
||||
val castContext = CastContext.getSharedInstance(mainActivity)
|
||||
playerNotificationService.castPlayer = CastPlayer(castContext).apply {
|
||||
setSessionAvailabilityListener(CastSessionAvailabilityListener())
|
||||
addListener(playerNotificationService.getPlayerListener())
|
||||
}
|
||||
Log.d(tag, "CAST Cast Player Applied")
|
||||
switchToPlayer(true)
|
||||
} catch (e: Exception) {
|
||||
Log.i(tag, "Cast is not available on this device. " +
|
||||
"Exception thrown when attempting to obtain CastContext. " + e.message)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
// media.setSession(castSession)
|
||||
// callback.onJoin(ChromecastUtilities.createSessionObject(castSession))
|
||||
}
|
||||
|
||||
override fun onSessionStartFailed(castSession: CastSession?, errCode: Int) {
|
||||
if (callback.onSessionStartFailed(errCode)) {
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSessionEnded(castSession: CastSession?, errCode: Int) {
|
||||
if (callback.onSessionEndedBeforeStart(errCode)) {
|
||||
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getSessionManager()?.addSessionManagerListener(newConnectionListener, CastSession::class.java)
|
||||
}
|
||||
|
||||
private fun getContext(): CastContext {
|
||||
return CastContext.getSharedInstance(mainActivity)
|
||||
}
|
||||
|
||||
private fun getSessionManager(): SessionManager? {
|
||||
return getContext().sessionManager
|
||||
}
|
||||
|
||||
private fun getMediaRouter(): MediaRouter? {
|
||||
return mainActivity?.let { MediaRouter.getInstance(it) }
|
||||
}
|
||||
|
||||
private fun getSession(): CastSession? {
|
||||
return getSessionManager()?.currentCastSession
|
||||
}
|
||||
|
||||
internal interface ConnectionCallback {
|
||||
/**
|
||||
* Successfully joined a session on a route.
|
||||
* @param jsonSession the session we joined
|
||||
*/
|
||||
fun onJoin(jsonSession: JSONObject?)
|
||||
|
||||
/**
|
||||
* Called if we received an error.
|
||||
* @param errorCode You can find the error meaning here:
|
||||
* https://developers.google.com/android/reference/com/google/android/gms/cast/CastStatusCodes
|
||||
* @return true if we are done listening for join, false, if we to keep listening
|
||||
*/
|
||||
fun onSessionStartFailed(errorCode: Int): Boolean
|
||||
|
||||
/**
|
||||
* Called when we detect a session ended event before session started.
|
||||
* See issues:
|
||||
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/49
|
||||
* https://github.com/jellyfin/cordova-plugin-chromecast/issues/48
|
||||
* @param errorCode error to output
|
||||
* @return true if we are done listening for join, false, if we to keep listening
|
||||
*/
|
||||
fun onSessionEndedBeforeStart(errorCode: Int): Boolean
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.google.android.gms.cast.CastMediaControlIntent
|
||||
import com.google.android.gms.cast.framework.CastOptions
|
||||
import com.google.android.gms.cast.framework.OptionsProvider
|
||||
import com.google.android.gms.cast.framework.SessionProvider
|
||||
import com.google.android.gms.cast.framework.media.CastMediaOptions
|
||||
|
||||
class CastOptionsProvider : OptionsProvider {
|
||||
override fun getCastOptions(context: Context): CastOptions {
|
||||
Log.d("CastOptionsProvider", "getCastOptions")
|
||||
return CastOptions.Builder()
|
||||
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID).setCastMediaOptions(
|
||||
CastMediaOptions.Builder()
|
||||
// We manage the media session and the notifications ourselves.
|
||||
.setMediaSessionEnabled(false)
|
||||
.setNotificationOptions(null)
|
||||
.build()
|
||||
)
|
||||
.setStopReceiverApplicationWhenEndingSession(true).build()
|
||||
}
|
||||
|
||||
override fun getAdditionalSessionProviders(context: Context): List<SessionProvider>? {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.ContentResolver
|
||||
import android.content.ContentUris
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.AssetFileDescriptor
|
||||
import android.database.Cursor
|
||||
import android.media.MediaPlayer
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import android.support.v4.media.MediaMetadataCompat
|
||||
import android.util.Log
|
||||
import androidx.annotation.AnyRes
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.net.toFile
|
||||
import androidx.core.net.toUri
|
||||
import com.bumptech.glide.Glide
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
|
||||
class LocalMediaManager {
|
||||
private var ctx: Context
|
||||
val tag = "LocalAudioManager"
|
||||
|
||||
constructor(ctx: Context) {
|
||||
this.ctx = ctx
|
||||
}
|
||||
|
||||
data class LocalAudio(val uri: Uri,
|
||||
val id: String,
|
||||
val name: String,
|
||||
val duration: Int,
|
||||
val size: Int,
|
||||
val coverUri: Uri?
|
||||
) {
|
||||
|
||||
fun toMediaMetadata(): MediaMetadataCompat {
|
||||
return MediaMetadataCompat.Builder().apply {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
|
||||
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
|
||||
|
||||
if (coverUri != null) {
|
||||
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
}
|
||||
val localAudioFiles = mutableListOf<LocalAudio>()
|
||||
|
||||
/**
|
||||
* get uri to drawable or any other resource type if u wish
|
||||
* @param context - context
|
||||
* @param drawableId - drawable res id
|
||||
* @return - uri
|
||||
*/
|
||||
fun getUriToDrawable(context: Context,
|
||||
@AnyRes drawableId: Int): Uri {
|
||||
return Uri.parse(ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||
+ '/' + context.resources.getResourceEntryName(drawableId))
|
||||
}
|
||||
|
||||
fun loadLocalAudio() {
|
||||
localAudioFiles.clear()
|
||||
|
||||
localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Anthem/AnthemSample.m4b"), "anthem_sample", "Anthem", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio))
|
||||
localAudioFiles += LocalAudio(Uri.parse("asset:///public/samples/Legend of Sleepy Hollow/LegendOfSleepyHollowSample.m4b"), "sleepy_hollow", "Legend of Sleepy Hollow", 60000, 10000, getUriToDrawable(ctx, R.drawable.exo_icon_localaudio))
|
||||
|
||||
// TODO: No longer reading in local audio files - just use samples
|
||||
// if (ContextCompat.checkSelfPermission(ctx, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||
// Log.e(tag, "Permission not granted to read from external storage")
|
||||
// return
|
||||
// }
|
||||
//
|
||||
// val collection =
|
||||
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
// MediaStore.Audio.Media.getContentUri(
|
||||
// MediaStore.VOLUME_EXTERNAL
|
||||
// )
|
||||
// } else {
|
||||
// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
|
||||
// }
|
||||
//
|
||||
// val proj = arrayOf(MediaStore.Audio.Media._ID, MediaStore.Audio.Media.DISPLAY_NAME, MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.SIZE)
|
||||
// val audioCursor: Cursor? = ctx.contentResolver.query(collection, proj, null, null, null)
|
||||
//
|
||||
// audioCursor?.use { cursor ->
|
||||
// // Cache column indices.
|
||||
// val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media._ID)
|
||||
// val nameColumn =
|
||||
// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DISPLAY_NAME)
|
||||
// val durationColumn =
|
||||
// cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.DURATION)
|
||||
// val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Audio.Media.SIZE)
|
||||
//
|
||||
// while (cursor.moveToNext()) {
|
||||
// // Get values of columns for a given video.
|
||||
// val id = cursor.getLong(idColumn)
|
||||
// val name = cursor.getString(nameColumn)
|
||||
// val duration = cursor.getInt(durationColumn)
|
||||
// val size = cursor.getInt(sizeColumn)
|
||||
//
|
||||
// val contentUri: Uri = ContentUris.withAppendedId(
|
||||
// MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
// id
|
||||
// )
|
||||
// Log.d(tag, "Found local audio file $name")
|
||||
// localAudioFiles += LocalAudio(contentUri, id.toString(), name, duration, size, null)
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// Log.d(tag, "${localAudioFiles.size} Local Audio Files found")
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,14 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Bundle
|
||||
import android.os.IBinder
|
||||
import android.app.DownloadManager
|
||||
import android.content.*
|
||||
import android.os.*
|
||||
import android.util.Log
|
||||
import com.example.myapp.MyNativeAudio
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.SimpleStorageHelper
|
||||
import com.getcapacitor.BridgeActivity
|
||||
|
||||
|
||||
class MainActivity : BridgeActivity() {
|
||||
private val tag = "MainActivity"
|
||||
|
||||
@@ -18,11 +17,43 @@ class MainActivity : BridgeActivity() {
|
||||
private lateinit var mConnection : ServiceConnection
|
||||
|
||||
lateinit var pluginCallback : () -> Unit
|
||||
lateinit var downloaderCallback : (String, Long) -> Unit
|
||||
|
||||
val storageHelper = SimpleStorageHelper(this)
|
||||
val storage = SimpleStorage(this)
|
||||
|
||||
val broadcastReceiver = object: BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
DownloadManager.ACTION_DOWNLOAD_COMPLETE -> {
|
||||
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
|
||||
downloaderCallback("complete", thisdlid)
|
||||
}
|
||||
DownloadManager.ACTION_NOTIFICATION_CLICKED -> {
|
||||
var thisdlid = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0L)
|
||||
downloaderCallback("clicked", thisdlid)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
Log.d(tag, "onCreate")
|
||||
registerPlugin(MyNativeAudio::class.java)
|
||||
registerPlugin(AudioDownloader::class.java)
|
||||
registerPlugin(StorageManager::class.java)
|
||||
|
||||
var filter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE).apply {
|
||||
addAction(DownloadManager.ACTION_NOTIFICATION_CLICKED)
|
||||
}
|
||||
registerReceiver(broadcastReceiver, filter)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
unregisterReceiver(broadcastReceiver)
|
||||
}
|
||||
|
||||
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||
@@ -30,12 +61,12 @@ class MainActivity : BridgeActivity() {
|
||||
|
||||
mConnection = object : ServiceConnection {
|
||||
override fun onServiceDisconnected(name: ComponentName) {
|
||||
Log.w(tag, "Service Disconnected")
|
||||
Log.w(tag, "Service Disconnected $name")
|
||||
mBounded = false
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||
Log.d(tag, "Service Connected")
|
||||
Log.d(tag, "Service Connected $name")
|
||||
|
||||
|
||||
mBounded = true
|
||||
@@ -62,4 +93,38 @@ class MainActivity : BridgeActivity() {
|
||||
val stopIntent = Intent(this, PlayerNotificationService::class.java)
|
||||
stopService(stopIntent)
|
||||
}
|
||||
|
||||
fun registerBroadcastReceiver(cb: (String, Long) -> Unit) {
|
||||
downloaderCallback = cb
|
||||
}
|
||||
|
||||
override fun onSaveInstanceState(outState: Bundle) {
|
||||
storageHelper.onSaveInstanceState(outState)
|
||||
super.onSaveInstanceState(outState)
|
||||
}
|
||||
|
||||
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
|
||||
super.onRestoreInstanceState(savedInstanceState)
|
||||
storageHelper.onRestoreInstanceState(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
super.onActivityResult(requestCode, resultCode, data)
|
||||
// Mandatory for Activity, but not for Fragment & ComponentActivity
|
||||
storageHelper.storage.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<String>, grantResults: IntArray) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
Log.d(tag, "onRequestPermissionResult $requestCode")
|
||||
permissions.forEach { Log.d(tag, "PERMISSION $it") }
|
||||
grantResults.forEach { Log.d(tag, "GRANTREUSLTS $it") }
|
||||
// Mandatory for Activity, but not for Fragment & ComponentActivity
|
||||
storageHelper.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
}
|
||||
|
||||
// override fun onUserInteraction() {
|
||||
// super.onUserInteraction()
|
||||
// Log.d(tag, "USER INTERACTION")
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
package com.example.myapp
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.audiobookshelf.app.Audiobook
|
||||
import com.audiobookshelf.app.MainActivity
|
||||
import com.audiobookshelf.app.PlayerNotificationService
|
||||
import com.getcapacitor.JSObject
|
||||
import com.getcapacitor.Plugin
|
||||
import com.getcapacitor.PluginCall
|
||||
import com.getcapacitor.PluginMethod
|
||||
import com.capacitorjs.plugins.app.AppPlugin
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
import org.json.JSONObject
|
||||
|
||||
@CapacitorPlugin(name = "MyNativeAudio")
|
||||
class MyNativeAudio : Plugin() {
|
||||
@@ -27,20 +23,37 @@ class MyNativeAudio : Plugin() {
|
||||
var foregroundServiceReady : () -> Unit = {
|
||||
playerNotificationService = mainActivity.foregroundService
|
||||
|
||||
playerNotificationService.setCustomObjectListener(object: PlayerNotificationService.MyCustomObjectListener {
|
||||
override fun onPlayingUpdate(isPlaying:Boolean) {
|
||||
playerNotificationService.setBridge(bridge)
|
||||
|
||||
playerNotificationService.setCustomObjectListener(object : PlayerNotificationService.MyCustomObjectListener {
|
||||
override fun onPlayingUpdate(isPlaying: Boolean) {
|
||||
emit("onPlayingUpdate", isPlaying)
|
||||
}
|
||||
override fun onMetadata(metadata:JSObject) {
|
||||
|
||||
override fun onMetadata(metadata: JSObject) {
|
||||
notifyListeners("onMetadata", metadata)
|
||||
}
|
||||
|
||||
override fun onPrepare(audiobookId: String, playWhenReady: Boolean) {
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("audiobookId", audiobookId)
|
||||
jsobj.put("playWhenReady", playWhenReady)
|
||||
notifyListeners("onPrepareMedia", jsobj)
|
||||
}
|
||||
|
||||
override fun onSleepTimerEnded(currentPosition: Long) {
|
||||
emit("onSleepTimerEnded", currentPosition)
|
||||
}
|
||||
|
||||
override fun onSleepTimerSet(sleepTimerEndTime: Long) {
|
||||
emit("onSleepTimerSet", sleepTimerEndTime)
|
||||
}
|
||||
})
|
||||
}
|
||||
mainActivity.pluginCallback = foregroundServiceReady
|
||||
|
||||
}
|
||||
|
||||
fun emit(evtName: String, value:Any) {
|
||||
fun emit(evtName: String, value: Any) {
|
||||
var ret:JSObject = JSObject()
|
||||
ret.put("value", value)
|
||||
notifyListeners(evtName, ret)
|
||||
@@ -53,15 +66,21 @@ class MyNativeAudio : Plugin() {
|
||||
Intent(mainActivity, PlayerNotificationService::class.java).also { intent ->
|
||||
ContextCompat.startForegroundService(mainActivity, intent)
|
||||
}
|
||||
} else {
|
||||
Log.w(tag, "Service already started --")
|
||||
}
|
||||
var jsobj = JSObject()
|
||||
|
||||
var audiobookStreamData:AudiobookStreamData = AudiobookStreamData(call.data)
|
||||
if (audiobookStreamData.playlistUrl == "" && audiobookStreamData.contentUrl == "") {
|
||||
Log.e(tag, "Invalid URL for init audio player")
|
||||
|
||||
jsobj.put("success", false)
|
||||
return call.resolve(jsobj)
|
||||
}
|
||||
|
||||
|
||||
var audiobook:Audiobook = Audiobook(call.data)
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.initPlayer(audiobook)
|
||||
call.resolve()
|
||||
playerNotificationService.initPlayer(audiobookStreamData)
|
||||
jsobj.put("success", true)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,9 +88,32 @@ class MyNativeAudio : Plugin() {
|
||||
fun getCurrentTime(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
var currentTime = playerNotificationService.getCurrentTime()
|
||||
Log.d(tag, "Get Current Time $currentTime")
|
||||
var bufferedTime = playerNotificationService.getBufferedTime()
|
||||
val ret = JSObject()
|
||||
ret.put("value", currentTime)
|
||||
ret.put("bufferedTime", bufferedTime)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getStreamSyncData(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
var isPlaying = playerNotificationService.getPlayStatus()
|
||||
var lastPauseTime = playerNotificationService.getTheLastPauseTime()
|
||||
Log.d(tag, "Get Last Pause Time $lastPauseTime")
|
||||
var currentTime = playerNotificationService.getCurrentTime()
|
||||
//if (!isPlaying) currentTime -= playerNotificationService.calcPauseSeekBackTime()
|
||||
var id = playerNotificationService.getCurrentAudiobookId()
|
||||
Log.d(tag, "Get Current id $id")
|
||||
var duration = playerNotificationService.getDuration()
|
||||
Log.d(tag, "Get duration $duration")
|
||||
val ret = JSObject()
|
||||
ret.put("lastPauseTime", lastPauseTime)
|
||||
ret.put("currentTime", currentTime)
|
||||
ret.put("isPlaying", isPlaying)
|
||||
ret.put("id", id)
|
||||
ret.put("duration", duration)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
@@ -102,19 +144,33 @@ class MyNativeAudio : Plugin() {
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seekForward10(call: PluginCall) {
|
||||
fun seekForward(call: PluginCall) {
|
||||
var amount:Long = call.getString("amount", "0")!!.toLong()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.seekForward10()
|
||||
playerNotificationService.seekForward(amount)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun seekBackward10(call: PluginCall) {
|
||||
fun seekBackward(call: PluginCall) {
|
||||
var amount:Long = call.getString("amount", "0")!!.toLong()
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.seekBackward10()
|
||||
playerNotificationService.seekBackward(amount)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setPlaybackSpeed(call: PluginCall) {
|
||||
var playbackSpeed:Float = call.getFloat("speed", 1.0f)!!
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.setPlaybackSpeed(playbackSpeed)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun terminateStream(call: PluginCall) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
@@ -122,4 +178,74 @@ class MyNativeAudio : Plugin() {
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun setSleepTimer(call: PluginCall) {
|
||||
var time:Long = call.getString("time", "360000")!!.toLong()
|
||||
var isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
var success:Boolean = playerNotificationService.sleepTimerManager.setSleepTimer(time, isChapterTime)
|
||||
val ret = JSObject()
|
||||
ret.put("success", success)
|
||||
call.resolve(ret)
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun getSleepTimerTime(call: PluginCall) {
|
||||
var time = playerNotificationService.sleepTimerManager.getSleepTimerTime()
|
||||
val ret = JSObject()
|
||||
ret.put("value", time)
|
||||
call.resolve(ret)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun increaseSleepTime(call: PluginCall) {
|
||||
var time:Long = call.getString("time", "300000")!!.toLong()
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.sleepTimerManager.increaseSleepTime(time)
|
||||
val ret = JSObject()
|
||||
ret.put("success", true)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun decreaseSleepTime(call: PluginCall) {
|
||||
var time:Long = call.getString("time", "300000")!!.toLong()
|
||||
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
playerNotificationService.sleepTimerManager.decreaseSleepTime(time)
|
||||
val ret = JSObject()
|
||||
ret.put("success", true)
|
||||
call.resolve()
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun cancelSleepTimer(call: PluginCall) {
|
||||
playerNotificationService.sleepTimerManager.cancelSleepTimer()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun requestSession(call: PluginCall) {
|
||||
Log.d(tag, "CAST REQUEST SESSION PLUGIN")
|
||||
|
||||
playerNotificationService.castManager.requestSession(mainActivity, object : CastManager.RequestSessionCallback() {
|
||||
override fun onError(errorCode: Int) {
|
||||
Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode")
|
||||
}
|
||||
|
||||
override fun onCancel() {
|
||||
Log.d(tag, "CAST REQUEST SESSION ON CANCEL")
|
||||
}
|
||||
|
||||
override fun onJoin(jsonSession: JSONObject?) {
|
||||
Log.d(tag, "CAST REQUEST SESSION ON JOIN")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import java.lang.Math.sqrt
|
||||
import kotlin.math.sqrt
|
||||
|
||||
class ShakeDetector : SensorEventListener {
|
||||
private var mListener: OnShakeListener? = null
|
||||
private var mShakeTimestamp: Long = 0
|
||||
private var mShakeCount = 0
|
||||
fun setOnShakeListener(listener: OnShakeListener?) {
|
||||
mListener = listener
|
||||
}
|
||||
|
||||
interface OnShakeListener {
|
||||
fun onShake(count: Int)
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(
|
||||
sensor: Sensor,
|
||||
accuracy: Int
|
||||
) { // ignore
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (mListener != null) {
|
||||
val x = event.values[0]
|
||||
val y = event.values[1]
|
||||
val z = event.values[2]
|
||||
val gX = x / SensorManager.GRAVITY_EARTH
|
||||
val gY = y / SensorManager.GRAVITY_EARTH
|
||||
val gZ = z / SensorManager.GRAVITY_EARTH
|
||||
// gForce will be close to 1 when there is no movement.
|
||||
val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ)
|
||||
if (gForce > SHAKE_THRESHOLD_GRAVITY) {
|
||||
val now = System.currentTimeMillis()
|
||||
// ignore shake events too close to each other (500ms)
|
||||
if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) {
|
||||
return
|
||||
}
|
||||
// reset the shake count after 3 seconds of no shakes
|
||||
if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) {
|
||||
mShakeCount = 0
|
||||
}
|
||||
mShakeTimestamp = now
|
||||
mShakeCount++
|
||||
mListener!!.onShake(mShakeCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
/*
|
||||
* The gForce that is necessary to register as shake.
|
||||
* Must be greater than 1G (one earth gravity unit).
|
||||
* You can install "G-Force", by Blake La Pierre
|
||||
* from the Google Play Store and run it to see how
|
||||
* many G's it takes to register a shake
|
||||
*/
|
||||
private const val SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f
|
||||
private const val SHAKE_SLOP_TIME_MS = 500
|
||||
private const val SHAKE_COUNT_RESET_TIME_MS = 3000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import java.util.*
|
||||
import kotlin.concurrent.schedule
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
const val SLEEP_EXTENSION_TIME = 900000L // 15m
|
||||
|
||||
class SleepTimerManager constructor(playerNotificationService:PlayerNotificationService) {
|
||||
private val tag = "SleepTimerManager"
|
||||
private val playerNotificationService:PlayerNotificationService = playerNotificationService
|
||||
|
||||
private var sleepTimerTask:TimerTask? = null
|
||||
private var sleepTimerRunning:Boolean = false
|
||||
private var sleepTimerEndTime:Long = 0L
|
||||
private var sleepTimerExtensionTime:Long = 0L
|
||||
private var sleepTimerFinishedAt:Long = 0L
|
||||
|
||||
private fun getCurrentTime():Long {
|
||||
return playerNotificationService.getCurrentTime()
|
||||
}
|
||||
|
||||
private fun getDuration():Long {
|
||||
return playerNotificationService.getDuration()
|
||||
}
|
||||
|
||||
private fun getIsPlaying():Boolean {
|
||||
return playerNotificationService.currentPlayer.isPlaying
|
||||
}
|
||||
|
||||
private fun setVolume(volume:Float) {
|
||||
playerNotificationService.currentPlayer.volume = volume
|
||||
}
|
||||
|
||||
private fun pause() {
|
||||
playerNotificationService.currentPlayer.pause()
|
||||
}
|
||||
|
||||
private fun play() {
|
||||
playerNotificationService.currentPlayer.play()
|
||||
}
|
||||
|
||||
private fun getSleepTimerTimeRemainingSeconds():Int {
|
||||
if (sleepTimerEndTime <= 0) return 0
|
||||
var sleepTimeRemaining = sleepTimerEndTime - getCurrentTime()
|
||||
return ((sleepTimeRemaining / 1000).toDouble()).roundToInt()
|
||||
}
|
||||
|
||||
fun getIsSleepTimerRunning():Boolean {
|
||||
return sleepTimerRunning
|
||||
}
|
||||
|
||||
fun setSleepTimer(time: Long, isChapterTime: Boolean) : Boolean {
|
||||
Log.d(tag, "Setting Sleep Timer for $time is chapter time $isChapterTime")
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerRunning = false
|
||||
sleepTimerFinishedAt = 0L
|
||||
|
||||
// Register shake sensor
|
||||
playerNotificationService.registerSensor()
|
||||
|
||||
var currentTime = getCurrentTime()
|
||||
if (isChapterTime) {
|
||||
if (currentTime > time) {
|
||||
Log.d(tag, "Invalid sleep timer - current time is already passed chapter time $time")
|
||||
return false
|
||||
}
|
||||
sleepTimerEndTime = time
|
||||
sleepTimerExtensionTime = SLEEP_EXTENSION_TIME
|
||||
} else {
|
||||
sleepTimerEndTime = currentTime + time
|
||||
sleepTimerExtensionTime = time
|
||||
}
|
||||
|
||||
if (sleepTimerEndTime > getDuration()) {
|
||||
sleepTimerEndTime = getDuration()
|
||||
}
|
||||
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
|
||||
sleepTimerRunning = true
|
||||
sleepTimerTask = Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||
Handler(Looper.getMainLooper()).post() {
|
||||
if (getIsPlaying()) {
|
||||
var sleepTimeSecondsRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
Log.d(tag, "Sleep TIMER time remaining $sleepTimeSecondsRemaining s")
|
||||
|
||||
if (sleepTimeSecondsRemaining <= 0) {
|
||||
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||
pause()
|
||||
|
||||
playerNotificationService.listener?.onSleepTimerEnded(getCurrentTime())
|
||||
clearSleepTimer()
|
||||
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||
} else if (sleepTimeSecondsRemaining <= 30) {
|
||||
// Start fading out audio
|
||||
var volume = sleepTimeSecondsRemaining / 30F
|
||||
Log.d(tag, "SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining")
|
||||
setVolume(volume)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
fun clearSleepTimer() {
|
||||
sleepTimerTask?.cancel()
|
||||
sleepTimerTask = null
|
||||
sleepTimerEndTime = 0
|
||||
sleepTimerRunning = false
|
||||
playerNotificationService.unregisterSensor()
|
||||
}
|
||||
|
||||
fun getSleepTimerTime():Long? {
|
||||
return sleepTimerEndTime
|
||||
}
|
||||
|
||||
fun cancelSleepTimer() {
|
||||
Log.d(tag, "Canceling Sleep Timer")
|
||||
clearSleepTimer()
|
||||
playerNotificationService.listener?.onSleepTimerSet(0)
|
||||
}
|
||||
|
||||
private fun extendSleepTime() {
|
||||
if (!sleepTimerRunning) return
|
||||
setVolume(1F)
|
||||
sleepTimerEndTime += sleepTimerExtensionTime
|
||||
if (sleepTimerEndTime > getDuration()) sleepTimerEndTime = getDuration()
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
}
|
||||
|
||||
fun checkShouldExtendSleepTimer() {
|
||||
if (!sleepTimerRunning) {
|
||||
if (sleepTimerFinishedAt <= 0L) return
|
||||
|
||||
var finishedAtDistance = System.currentTimeMillis() - sleepTimerFinishedAt
|
||||
if (finishedAtDistance > SLEEP_TIMER_WAKE_UP_EXPIRATION) // 2 minutes
|
||||
{
|
||||
Log.d(tag, "Sleep timer finished over 2 mins ago, clearing it")
|
||||
sleepTimerFinishedAt = 0L
|
||||
return
|
||||
}
|
||||
|
||||
var newSleepTime = if (sleepTimerExtensionTime >= 0) sleepTimerExtensionTime else SLEEP_EXTENSION_TIME
|
||||
setSleepTimer(newSleepTime, false)
|
||||
play()
|
||||
return
|
||||
}
|
||||
// Only extend if within 30 seconds of finishing
|
||||
var sleepTimeRemaining = getSleepTimerTimeRemainingSeconds()
|
||||
if (sleepTimeRemaining <= 30) extendSleepTime()
|
||||
}
|
||||
|
||||
fun handleShake() {
|
||||
Log.d(tag, "HANDLE SHAKE HERE")
|
||||
if (sleepTimerRunning || sleepTimerFinishedAt > 0L) checkShouldExtendSleepTimer()
|
||||
}
|
||||
|
||||
fun increaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Increase Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
var newSleepEndTime = sleepTimerEndTime + time
|
||||
sleepTimerEndTime = if (newSleepEndTime >= getDuration()) {
|
||||
getDuration()
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
setVolume(1F)
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
}
|
||||
|
||||
fun decreaseSleepTime(time: Long) {
|
||||
Log.d(tag, "Decrease Sleep time $time")
|
||||
if (!sleepTimerRunning) return
|
||||
var newSleepEndTime = sleepTimerEndTime - time
|
||||
sleepTimerEndTime = if (newSleepEndTime <= 1000) {
|
||||
// End sleep timer in 1 second
|
||||
getCurrentTime() + 1000
|
||||
} else {
|
||||
newSleepEndTime
|
||||
}
|
||||
setVolume(1F)
|
||||
playerNotificationService.listener?.onSleepTimerSet(sleepTimerEndTime)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package com.audiobookshelf.app
|
||||
|
||||
import android.database.Cursor
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.documentfile.provider.DocumentFile
|
||||
|
||||
import com.anggrayudi.storage.SimpleStorage
|
||||
import com.anggrayudi.storage.callback.FolderPickerCallback
|
||||
import com.anggrayudi.storage.callback.StorageAccessCallback
|
||||
import com.anggrayudi.storage.file.*
|
||||
import com.getcapacitor.*
|
||||
import com.getcapacitor.annotation.CapacitorPlugin
|
||||
|
||||
@CapacitorPlugin(name = "StorageManager")
|
||||
class StorageManager : Plugin() {
|
||||
private val TAG = "StorageManager"
|
||||
|
||||
lateinit var mainActivity:MainActivity
|
||||
|
||||
data class MediaFile(val uri: Uri, val name: String, val simplePath: String, val size: Long, val type: String, val isAudio: Boolean) {
|
||||
fun toJSObject() : JSObject {
|
||||
var obj = JSObject()
|
||||
obj.put("uri", this.uri)
|
||||
obj.put("name", this.name)
|
||||
obj.put("simplePath", this.simplePath)
|
||||
obj.put("size", this.size)
|
||||
obj.put("type", this.type)
|
||||
obj.put("isAudio", this.isAudio)
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaFolder(val uri: Uri, val name: String, val simplePath: String, val mediaFiles:List<MediaFile>) {
|
||||
fun toJSObject() : JSObject {
|
||||
var obj = JSObject()
|
||||
obj.put("uri", this.uri)
|
||||
obj.put("name", this.name)
|
||||
obj.put("simplePath", this.simplePath)
|
||||
obj.put("files", this.mediaFiles.map { it.toJSObject() })
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
override fun load() {
|
||||
mainActivity = (activity as MainActivity)
|
||||
|
||||
mainActivity.storage.storageAccessCallback = object : StorageAccessCallback {
|
||||
override fun onRootPathNotSelected(
|
||||
requestCode: Int,
|
||||
rootPath: String,
|
||||
uri: Uri,
|
||||
selectedStorageType: StorageType,
|
||||
expectedStorageType: StorageType
|
||||
) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onCanceledByUser(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onExpectedStorageNotSelected(requestCode: Int, selectedFolder: DocumentFile, selectedStorageType: StorageType, expectedBasePath: String, expectedStorageType: StorageType) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
|
||||
override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) {
|
||||
Log.d(TAG, "STORAGE ACCESS CALLBACK")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun selectFolder(call: PluginCall) {
|
||||
mainActivity.storage.folderPickerCallback = object : FolderPickerCallback {
|
||||
override fun onFolderSelected(requestCode: Int, folder: DocumentFile) {
|
||||
Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}")
|
||||
|
||||
var absolutePath = folder.getAbsolutePath(activity)
|
||||
var storageId = folder.getStorageId(activity)
|
||||
var storageType = folder.getStorageType(activity)
|
||||
var simplePath = folder.getSimplePath(activity)
|
||||
var basePath = folder.getBasePath(activity)
|
||||
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("uri", folder.uri)
|
||||
jsobj.put("absolutePath", absolutePath)
|
||||
jsobj.put("storageId", storageId)
|
||||
jsobj.put("storageType", storageType)
|
||||
jsobj.put("simplePath", simplePath)
|
||||
jsobj.put("basePath", basePath)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
override fun onStorageAccessDenied(requestCode: Int, folder: DocumentFile?, storageType: StorageType) {
|
||||
Log.e(TAG, "STORAGE ACCESS DENIED")
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("error", "Access Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
override fun onStoragePermissionDenied(requestCode: Int) {
|
||||
Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode")
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("error", "Permission Denied")
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
}
|
||||
mainActivity.storage.openFolderPicker(6)
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.R)
|
||||
@PluginMethod
|
||||
fun requestStoragePermission(call: PluginCall) {
|
||||
Log.d(TAG, "Request Storage Permissions")
|
||||
mainActivity.storageHelper.requestStorageAccess()
|
||||
call.resolve()
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun checkStoragePermission(call: PluginCall) {
|
||||
var res = false
|
||||
|
||||
if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) {
|
||||
res = SimpleStorage.hasStoragePermission(context)
|
||||
Log.d(TAG, "Check Storage Access $res")
|
||||
} else {
|
||||
Log.d(TAG, "Has permission on Android 10 or up")
|
||||
res = true
|
||||
}
|
||||
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("value", res)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun checkFolderPermissions(call: PluginCall) {
|
||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
Log.d(TAG, "Check Folder Permissions for $folderUrl")
|
||||
|
||||
var hasAccess = SimpleStorage.hasStorageAccess(context,folderUrl,true)
|
||||
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("value", hasAccess)
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
@PluginMethod
|
||||
fun searchFolder(call: PluginCall) {
|
||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
Log.d(TAG, "Searching folder $folderUrl")
|
||||
|
||||
var df: DocumentFile? = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
|
||||
|
||||
if (df == null) {
|
||||
Log.e(TAG, "Folder Doc File Invalid $folderUrl")
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("folders", JSArray())
|
||||
jsobj.put("files", JSArray())
|
||||
call.resolve(jsobj)
|
||||
return
|
||||
}
|
||||
|
||||
Log.d(TAG, "Folder as DF ${df.isDirectory} | ${df.getSimplePath(context)} | ${df.getBasePath(context)} | ${df.name}")
|
||||
|
||||
var mediaFolders = mutableListOf<MediaFolder>()
|
||||
var foldersFound = df.search(false, DocumentFileType.FOLDER)
|
||||
|
||||
foldersFound.forEach {
|
||||
Log.d(TAG, "Iterating over Folder Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri}")
|
||||
var folderName = it.name ?: ""
|
||||
var mediaFiles = mutableListOf<MediaFile>()
|
||||
|
||||
var filesInFolder = it.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
||||
filesInFolder.forEach { it2 ->
|
||||
var mimeType = it2?.mimeType ?: ""
|
||||
var filename = it2?.name ?: ""
|
||||
var isAudio = mimeType.startsWith("audio")
|
||||
Log.d(TAG, "Found $mimeType file $filename in folder $folderName")
|
||||
var imageFile = MediaFile(it2.uri, filename, it2.getSimplePath(context), it2.length(), mimeType, isAudio)
|
||||
mediaFiles.add(imageFile)
|
||||
}
|
||||
if (mediaFiles.size > 0) {
|
||||
mediaFolders.add(MediaFolder(it.uri, folderName, it.getSimplePath(context), mediaFiles))
|
||||
}
|
||||
}
|
||||
|
||||
// Files in root dir
|
||||
var rootMediaFiles = mutableListOf<MediaFile>()
|
||||
var mediaFilesFound:List<DocumentFile> = df.search(false, DocumentFileType.FILE, arrayOf("audio/*", "image/*"))
|
||||
mediaFilesFound.forEach {
|
||||
Log.d(TAG, "Folder Root File Found ${it.name} | ${it.getSimplePath(context)} | URI: ${it.uri} | ${it.mimeType}")
|
||||
var mimeType = it?.mimeType ?: ""
|
||||
var filename = it?.name ?: ""
|
||||
var isAudio = mimeType.startsWith("audio")
|
||||
Log.d(TAG, "Found $mimeType file $filename in root folder")
|
||||
var imageFile = MediaFile(it.uri, filename, it.getSimplePath(context), it.length(), mimeType, isAudio)
|
||||
rootMediaFiles.add(imageFile)
|
||||
}
|
||||
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("folders", mediaFolders.map{ it.toJSObject() })
|
||||
jsobj.put("files", rootMediaFiles.map{ it.toJSObject() })
|
||||
call.resolve(jsobj)
|
||||
}
|
||||
|
||||
|
||||
@PluginMethod
|
||||
fun delete(call: PluginCall) {
|
||||
var url = call.data.getString("url", "").toString()
|
||||
var coverUrl = call.data.getString("coverUrl", "").toString()
|
||||
var folderUrl = call.data.getString("folderUrl", "").toString()
|
||||
|
||||
if (folderUrl != "") {
|
||||
Log.d(TAG, "CALLED DELETE FOLDER: $folderUrl")
|
||||
var folder = DocumentFileCompat.fromUri(context, Uri.parse(folderUrl))
|
||||
var success = folder?.deleteRecursively(context)
|
||||
var jsobj = JSObject()
|
||||
jsobj.put("success", success)
|
||||
call.resolve()
|
||||
} else {
|
||||
// Older audiobooks did not store a folder url, use cover and audiobook url
|
||||
var abExists = checkUriExists(Uri.parse(url))
|
||||
if (abExists) {
|
||||
var abfile = DocumentFileCompat.fromUri(context, Uri.parse(url))
|
||||
abfile?.delete()
|
||||
}
|
||||
|
||||
var coverExists = checkUriExists(Uri.parse(coverUrl))
|
||||
if (coverExists) {
|
||||
var coverfile = DocumentFileCompat.fromUri(context, Uri.parse(coverUrl))
|
||||
coverfile?.delete()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun checkUriExists(uri: Uri?): Boolean {
|
||||
if (uri == null) return false
|
||||
val resolver = context.contentResolver
|
||||
var cursor: Cursor? = null
|
||||
return try {
|
||||
cursor = resolver.query(uri, null, null, null, null)
|
||||
//cursor null: content Uri was invalid or some other error occurred
|
||||
//cursor.moveToFirst() false: Uri was ok but no entry found.
|
||||
(cursor != null && cursor.moveToFirst())
|
||||
} catch (t: Throwable) {
|
||||
false
|
||||
} finally {
|
||||
try {
|
||||
cursor?.close()
|
||||
} catch (t: Throwable) {
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.127451"
|
||||
android:scaleY="1.127451"
|
||||
android:translateX="-1.5294118"
|
||||
android:translateY="-1.5294118">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M4,6H2v14c0,1.1 0.9,2 2,2h14v-2H4V6z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,2L8,2c-1.1,0 -2,0.9 -2,2v12c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2L22,4c0,-1.1 -0.9,-2 -2,-2zM20,12l-2.5,-1.5L15,12L15,4h5v8z"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.127451"
|
||||
android:scaleY="1.127451"
|
||||
android:translateX="-1.5294118"
|
||||
android:translateY="-1.5294118">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20.13,5.41l-1.41,-1.41l-9.19,9.19l-4.25,-4.24l-1.41,1.41l5.66,5.66z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M5,18h14v2h-14z"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,15 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.127451"
|
||||
android:scaleY="1.127451"
|
||||
android:translateX="-1.5294118"
|
||||
android:translateY="-1.5294118">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12,3v9.28c-0.47,-0.17 -0.97,-0.28 -1.5,-0.28C8.01,12 6,14.01 6,16.5S8.01,21 10.5,21c2.31,0 4.2,-1.75 4.45,-4H15V6h4V3h-7z"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,21 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.127451"
|
||||
android:scaleY="1.127451"
|
||||
android:translateX="-1.5294118"
|
||||
android:translateY="-1.5294118">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
|
||||
</group>
|
||||
</vector>
|
||||
@@ -0,0 +1,21 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="#FFFFFF">
|
||||
<group android:scaleX="1.127451"
|
||||
android:scaleY="1.127451"
|
||||
android:translateX="-1.5294118"
|
||||
android:translateY="-1.5294118">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 276 B |
|
After Width: | Height: | Size: 215 B |
|
After Width: | Height: | Size: 462 B |
|
After Width: | Height: | Size: 250 B |
|
After Width: | Height: | Size: 444 B |
|
After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 199 B |
|
After Width: | Height: | Size: 176 B |
|
After Width: | Height: | Size: 335 B |
|
After Width: | Height: | Size: 186 B |
|
After Width: | Height: | Size: 329 B |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
Before Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 309 B |
|
After Width: | Height: | Size: 355 B |
|
After Width: | Height: | Size: 591 B |
|
After Width: | Height: | Size: 289 B |
|
After Width: | Height: | Size: 598 B |
|
After Width: | Height: | Size: 430 B |
|
After Width: | Height: | Size: 448 B |
|
After Width: | Height: | Size: 869 B |
|
After Width: | Height: | Size: 477 B |
|
After Width: | Height: | Size: 843 B |
@@ -0,0 +1,7 @@
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M18,13c0,3.31 -2.69,6 -6,6s-6,-2.69 -6,-6s2.69,-6 6,-6v4l5,-5l-5,-5v4c-4.42,0 -8,3.58 -8,8c0,4.42 3.58,8 8,8s8,-3.58 8,-8H18z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M10.86,15.94l0,-4.27l-0.09,0l-1.77,0.63l0,0.69l1.01,-0.31l0,3.26z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M12.25,13.44v0.74c0,1.9 1.31,1.82 1.44,1.82c0.14,0 1.44,0.09 1.44,-1.82v-0.74c0,-1.9 -1.31,-1.82 -1.44,-1.82C13.55,11.62 12.25,11.53 12.25,13.44zM14.29,13.32v0.97c0,0.77 -0.21,1.03 -0.59,1.03c-0.38,0 -0.6,-0.26 -0.6,-1.03v-0.97c0,-0.75 0.22,-1.01 0.59,-1.01C14.07,12.3 14.29,12.57 14.29,13.32z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,7 @@
|
||||
<vector android:height="24dp" android:tint="#FFFFFF"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M11.99,5V1l-5,5l5,5V7c3.31,0 6,2.69 6,6s-2.69,6 -6,6s-6,-2.69 -6,-6h-2c0,4.42 3.58,8 8,8s8,-3.58 8,-8S16.41,5 11.99,5z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M10.89,16h-0.85v-3.26l-1.01,0.31v-0.69l1.77,-0.63h0.09V16z"/>
|
||||
<path android:fillColor="@android:color/white" android:pathData="M15.17,14.24c0,0.32 -0.03,0.6 -0.1,0.82s-0.17,0.42 -0.29,0.57s-0.28,0.26 -0.45,0.33s-0.37,0.1 -0.59,0.1s-0.41,-0.03 -0.59,-0.1s-0.33,-0.18 -0.46,-0.33s-0.23,-0.34 -0.3,-0.57s-0.11,-0.5 -0.11,-0.82V13.5c0,-0.32 0.03,-0.6 0.1,-0.82s0.17,-0.42 0.29,-0.57s0.28,-0.26 0.45,-0.33s0.37,-0.1 0.59,-0.1s0.41,0.03 0.59,0.1c0.18,0.07 0.33,0.18 0.46,0.33s0.23,0.34 0.3,0.57s0.11,0.5 0.11,0.82V14.24zM14.32,13.38c0,-0.19 -0.01,-0.35 -0.04,-0.48s-0.07,-0.23 -0.12,-0.31s-0.11,-0.14 -0.19,-0.17s-0.16,-0.05 -0.25,-0.05s-0.18,0.02 -0.25,0.05s-0.14,0.09 -0.19,0.17s-0.09,0.18 -0.12,0.31s-0.04,0.29 -0.04,0.48v0.97c0,0.19 0.01,0.35 0.04,0.48s0.07,0.24 0.12,0.32s0.11,0.14 0.19,0.17s0.16,0.05 0.25,0.05s0.18,-0.02 0.25,-0.05s0.14,-0.09 0.19,-0.17s0.09,-0.19 0.11,-0.32s0.04,-0.29 0.04,-0.48V13.38z"/>
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
@@ -1,5 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
</adaptive-icon>
|
||||
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 393 B After Width: | Height: | Size: 803 B |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 8.6 KiB After Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 258 B After Width: | Height: | Size: 564 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 3.2 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 536 B After Width: | Height: | Size: 1011 B |
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 923 B After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 30 KiB After Width: | Height: | Size: 15 KiB |
@@ -17,6 +17,6 @@
|
||||
|
||||
|
||||
<style name="AppTheme.NoActionBarLaunch" parent="AppTheme.NoActionBar">
|
||||
<item name="android:background">@drawable/splash</item>
|
||||
<item name="android:background">@drawable/screen</item>
|
||||
</style>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<automotiveApp>
|
||||
<uses name="media" />
|
||||
</automotiveApp>
|
||||
@@ -2,14 +2,5 @@
|
||||
<widget version="1.0.0" xmlns="http://www.w3.org/ns/widgets" xmlns:cdv="http://cordova.apache.org/ns/1.0">
|
||||
<access origin="*" />
|
||||
|
||||
<feature name="File">
|
||||
<param name="android-package" value="org.apache.cordova.file.FileUtils"/>
|
||||
<param name="onload" value="true"/>
|
||||
</feature>
|
||||
|
||||
<feature name="Media">
|
||||
<param name="android-package" value="org.apache.cordova.media.AudioHandler"/>
|
||||
</feature>
|
||||
|
||||
|
||||
</widget>
|
||||