Compare commits

..
Author SHA1 Message Date
advplyr 1a555eab63 Version bump 0.9.38-beta 2022-01-20 14:02:09 -06:00
advplyr 4cdcbf79d7 Add:Check for OnePlus on playing ab with local cover #35 2022-01-20 13:57:55 -06:00
advplyr c07b527a1d Fix:User accessible libraries #84 2022-01-16 14:14:02 -06:00
advplyr a2031f1d88 Version bump 0.9.37-beta 2022-01-13 16:55:19 -06:00
advplyr 42fec4831e Fix:Native progress sync sending time in ms #78, Fix:Chapter start/end times are strings #83 2022-01-13 15:44:16 -06:00
advplyr 9da21b57c7 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-01-08 14:33:11 -06:00
advplyr 2d7b539e90 Version bump v0.9.36-beta 2022-01-08 14:33:03 -06:00
advplyr 4dbaf3c864 Add:Android auto to use saved playback rate #66 2022-01-08 14:25:28 -06:00
advplyr e6e7ad8266 Change:Use new server cover url for cached and compressed covers 2022-01-08 13:56:17 -06:00
advplyr 080a08aebc Fix:Check local cover image read permission and remove cover #35 2022-01-08 12:57:30 -06:00
advplyr 8d4831f912 Change:ios version string 2022-01-06 18:35:12 -06:00
advplyr 72732025ae Change:Remove android auto reference for apple app store approval 2022-01-06 17:11:09 -06:00
advplyr b0c316d2f0 Fix:Audio player dropdown menu 2022-01-05 19:19:59 -06:00
advplyr f7a2393d99 Fix:Downloaded audiobook progress sync #77, Fix:Downloads page not showing, Fix: Audio player dropdown menu staying open 2022-01-05 19:09:18 -06:00
advplyr 6229a7f0a8 Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2022-01-04 18:40:57 -06:00
advplyr a0768da143 Fix:Audiobook page progress time remaining for 100% #73 2022-01-04 18:40:49 -06:00
advplyr 04b453060d Add:iOS icons 2022-01-01 12:10:45 -06:00
advplyr 4bf75c606a Add:iOS audio player 2021-12-31 16:57:53 -06:00
advplyr 4496b1170c Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2021-12-27 16:29:48 -06:00
advplyr 932bc30172 Fix:Stream sync make sure server url and token exist 2021-12-27 16:29:42 -06:00
advplyrandGitHub 09d13b9b71 Merge pull request #69 from tvories/feat/add_issue_templates
Add issue templates
2021-12-23 20:23:19 -06:00
advplyrandGitHub 16df9a191a Merge pull request #68 from tvories/feat/dev_docs
Clarified Android and iOS setup, added more docs
2021-12-23 20:21:13 -06:00
tvories 0ddb5d65c2 feat: add issue templates 2021-12-23 19:18:19 -07:00
Taylor VoriesandGitHub 0f99ecabf7 Merge branch 'advplyr:master' into feat/dev_docs 2021-12-23 19:07:30 -07:00
tvories 01e89cc916 fix: fixed android to xcode 2021-12-23 11:16:38 -07:00
tvories fc7867ef22 feat: added ios instructions 2021-12-23 11:12:16 -07:00
tvories fe7b715062 feat: added additional android clarification 2021-12-23 10:30:50 -07:00
tvories 9b3b865878 feat: added info about capacitor and nuxtjs and clarified instructions were for android only 2021-12-23 10:24:27 -07:00
advplyrandGitHub a29e41e677 Merge pull request #67 from tvories/feat/dev_docs
Add contribution/environment setup docs
2021-12-23 09:17:27 -06:00
tvories 1f8d441ef0 feat: update docs 2021-12-22 22:59:46 -07:00
tvories 658fcb5e8b fix: changed wording 2021-12-22 22:59:46 -07:00
tvories d361b75360 feat: Added mac setup 2021-12-22 22:59:46 -07:00
tvories fb75a7fefa feat: initial doc update 2021-12-22 11:37:04 -07:00
advplyr 7331389add Version bump v0.9.35-beta 2021-12-19 19:04:14 -06:00
advplyr f59e6521fc Add:Native progress sync for android auto streaming #41, Change:Android auto only show samples when no audiobooks 2021-12-19 18:58:26 -06:00
advplyr 1a7f90c93b Change:Audio stream buffer times #63 2021-12-19 12:40:52 -06:00
advplyr 0fcec4b861 Fix:Link orphan downloads 2021-12-18 19:35:07 -06:00
advplyr f11efd93c7 Change:Cast support use audio tracks instead of HLS #11, Clean up playernotificationmanager 2021-12-18 18:26:50 -06:00
advplyr b9de407c8a Version bump v0.9.34-beta 2021-12-11 19:53:16 -06:00
advplyr a295889a52 Add:Android auto show currently reading 2021-12-11 19:49:22 -06:00
advplyr 6faa9b6324 Add:Option to use chapter track #46, Change:Playback speed increment by 0.1 #57 2021-12-11 13:20:20 -06:00
advplyr d3a2c79a55 Fix:Native back button go back or ask before quitting #52, Fix:Appbar back button 2021-12-10 16:31:54 -06:00
advplyr 6c08e91dfc Update readme screenshot 2021-12-09 18:24:42 -06:00
advplyr 8a542c8776 Version bump 0.9.33-beta 2021-12-06 19:11:00 -06:00
advplyr f180fc792f Fix:Load libraries on connect 2021-12-06 18:40:42 -06:00
advplyr ab531edac2 Version bump v\0.9.32-beta 2021-12-05 18:36:32 -06:00
advplyr 3232b519d3 Finish lazy bookshelf,Fix:Async sql db,Fix:Load user progress 2021-12-05 18:31:47 -06:00
advplyr 37d3021302 Add:Lazy bookshelf 2021-12-04 19:56:29 -06:00
advplyr 446c6756ed Add:Obnoxious alert stating the app does not provide any content 2021-11-27 17:48:40 -06:00
advplyr dc8b57f8cf Fix:Sleep timer shake to wake up to work when screen is locked 2021-11-27 13:35:21 -06:00
advplyr a7de535123 Fix:Sleep timer volume reset #10, Change:Sleep timer resets to originally selected time only if less than 30s or finished within 2m #10, Add:Sleep timer +/- buttons 2021-11-27 12:15:17 -06:00
advplyr edc45addc9 Change:Sleep timer shake to extend time 15m and fade out audio #44 #10, Change:Audio player show time remaining accounting for current playback speed #40, Fix:highlighted current chapter, Update api endpoints 2021-11-26 18:27:18 -06:00
advplyr d3343d722f Add: sample audio files for Google android auto test team 2021-11-21 15:06:57 -06:00
advplyr 708f399916 Add: Login/connection error messages 2021-11-21 06:54:10 -06:00
advplyr f5d455feb1 Fix:Initial audiobook progress sync, Fix:Reset stream on logout 2021-11-20 19:25:01 -06:00
advplyr 6bb8dfeffa Fix: check permission before media store query, Add: start of casting 2021-11-20 17:27:03 -06:00
advplyr f40e971b90 Fix: more sync issues, Add: link audiobooks with folder names in selected download folder 2021-11-20 10:59:34 -06:00
advplyr 3b6e7e1ce2 Add: Search page, Add: Bookshelf list view, Fix: Audiobook progress sync, Fix: Download audiobook button, Change: User audiobook data to use SQL table 2021-11-19 20:00:34 -06:00
advplyr b6dd37b7f6 Add: Library list view 2021-11-19 10:29:26 -06:00
advplyr fd79baed61 Add: New search page 2021-11-19 10:14:36 -06:00
advplyr 2965ccb513 Add: play local audio files in android auto to demonstrate to the google app testers that the android auto player works 2021-11-18 18:22:38 -06:00
advplyr 0abefbd9bc Fix: android auto requirements, Change: New UI #33 2021-11-14 19:59:34 -06:00
advplyr bf8e48fd27 Add: Android auto first attempt 2021-11-11 20:38:51 -06:00
advplyr 52e3ea0a99 Change: laying the groundwork for android auto 2021-11-10 20:39:25 -06:00
advplyr 44f535020d Add: network security config with user certificates accepted #28 2021-11-07 15:41:45 -06:00
advplyr ba00acaef5 Fix: Login/logout socket connection issue #29 2021-11-07 15:20:16 -06:00
advplyr 65706a52fc Add: Bookmarks, Fix: Playback rate updating, Fix: Sleep timer countdown, Fix: Prev chapter btn, Change: Loading indicator for new audio player, Fix: UI alignment issues #26 2021-11-02 19:44:42 -05:00
advplyr 08195af0dd Add: New audio player w/ fullscreen and condensed views 2021-11-01 21:06:51 -05:00
advplyr 082c303caf Add: Sleep Timer set to end of chapter #24, Change: Scroll to current chapter in modal 2021-10-31 14:35:34 -05:00
advplyr 3b462b27b5 Add: Sleep Timers #24 2021-10-30 13:31:21 -05:00
advplyr bff99f292b Version update 2021-10-30 06:31:47 -05:00
advplyr 77784e4988 Change: Route for resetting progress 2021-10-30 06:22:42 -05:00
advplyrandGitHub 3f63c4d157 Merge pull request #21 from svdztn/fix_playWhenReady
Fix playWhenReady
2021-10-30 06:21:44 -05:00
svd 5757d90b05 fix media notification operation without seekback 2021-10-29 11:05:25 +08:00
svd 37fea5c772 fix playWhenReady, prevent open stream with closed id 2021-10-29 10:57:48 +08:00
advplyr ebf628315c Fix: download single track audiobooks #15, Change: check write permission when selecting folder #13, Add: Show folders and files list in user selected folder, Fix: Seek back only if audiobook was played #20 2021-10-28 09:37:31 -05:00
advplyrandGitHub de4340487b Merge pull request #20 from svdztn/fix_seekback_with_no_play
Fix seekback with no play & progress bar data loss
2021-10-27 14:16:34 -05:00
svd 55f032d887 fix progress bar data loss after restore from app destroy when playing is on 2021-10-27 15:12:36 +08:00
svd fde6a4e8ec rst "no_sync" after STATE_READY 2021-10-27 12:59:44 +08:00
svd 7c87d79591 add noSyncUpdateTime symbol, don't sync progress to srv before start playing 2021-10-27 11:34:50 +08:00
advplyr ab8e06283f Version update 2021-10-26 16:57:15 -05:00
advplyrandGitHub 1da0223461 Merge pull request #18 from svdztn/stream-sync
Stream sync
2021-10-26 16:42:35 -05:00
advplyr 2b14460bf1 fix min pause time to 3000 ms 2021-10-26 16:42:11 -05:00
svd 504393ff2b del debug log 2021-10-26 11:10:33 +08:00
svdztnandGitHub ad823ca486 Merge branch 'advplyr:master' into stream-sync 2021-10-26 11:02:27 +08:00
svd 4935155078 add auto seekback 2021-10-26 10:36:08 +08:00
svd 02c0a3428e native plugin: change setMediaSource() to the api with startPositionMs, avoid call output-0.ts (will cause server retranscoding) 2021-10-26 10:32:21 +08:00
svd b9de9cf9b0 native plugin: add getStreamSyncData() 2021-10-26 10:24:48 +08:00
svd 274a683be7 restore from App “stop" & "destroyed", get data from player, avoid progress loss 2021-10-26 10:19:54 +08:00
advplyr 4049596025 Fix: Sync audiobook progress, fix: multiple socket connections getting established 2021-10-25 21:13:10 -05:00
advplyr aef09ebada Fix: add user token to single file download request #16 2021-10-25 05:42:46 -05:00
advplyr cd3277a5f9 Fix unnecessary download thread, fix load audiobooks having different uri 2021-10-18 19:40:05 -05:00
advplyr 5a025b3a03 Ebook and comic reader #14 2021-10-17 20:20:00 -05:00
advplyr 3234e7ef06 Readme screenshot 2021-10-16 16:16:42 -05:00
advplyr 56a70aefaf Add: bookshelf list view, add: sort by most recent 2021-10-16 15:50:13 -05:00
advplyr 6dbbfdbc04 Fix: downloaded filename is different in android 10 #13 2021-10-16 10:08:13 -05:00
advplyr 993ff5341d Support server v1.4.0, multiple libraries, new static request routes 2021-10-06 07:24:15 -05:00
advplyr a520a1fd8e Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app 2021-10-03 14:45:13 -05:00
advplyr a4ab5c8d87 Increment version, remove android auto 2021-10-03 14:45:11 -05:00
advplyr 7778c78b97 merge
Merge branch 'master' of https://github.com/advplyr/audiobookshelf-app
2021-10-03 19:38:04 +00:00
advplyrandGitHub d05cf46574 Merge pull request #12 from svdztn/media_btn
Media button & connect fail
2021-10-03 06:45:51 -05:00
advplyr ee118abf59 Update cover art urls 2021-10-03 06:43:43 -05:00
svdztn 683af098de do not delete ServerUrl when connect fail 2021-10-03 13:51:28 +08:00
svdztn f1287dda07 handle media button: seekBackward and seekForward 2021-10-03 13:49:04 +08:00
advplyr b8063b8d94 Adding progress filter, filter dot, current chapter highlighted 2021-09-21 18:32:59 -05:00
advplyr 9c5f79d54f Initial android auto support, fix bug saving user settings 2021-09-21 07:11:59 -05:00
advplyr e124d3858f Support for android 8+, selectable download location, permissions check, recursive delete 2021-09-19 18:44:10 -05:00
advplyr b4ffe0fc83 Update min version to 10 2021-09-17 05:58:45 -05:00
advplyr 07fd075619 Fix id passed to delete prepared download 2021-09-16 19:58:04 -05:00
advplyr 87f1deff19 Downloads go into sub-directory, fix text overflow in downloads modal 2021-09-16 19:41:07 -05:00
advplyr 8232cfb796 Version increment and download cover to stream container 2021-09-14 18:24:02 -05:00
advplyr a412c9d359 Offline support, downloading, syncing progress 2021-09-12 18:37:08 -05:00
advplyr e97218f2e8 Add chapter support, handle audio focus 2021-09-08 12:17:02 -05:00
advplyr 2c27fb3108 Add global search, update filters for new filter model 2021-09-05 15:22:30 -05:00
advplyr 4420375f2a Update seek forward/backward time to 10s 2021-09-05 12:09:29 -05:00
advplyr 811fc91fa7 Update fastforward/rewind icons and show on lock screen, move logout btn, fix fallback cover art 2021-09-05 10:37:08 -05:00
advplyr b0d1f51678 Readme update 2021-09-04 16:05:22 -05:00
advplyr 619b6f3686 Check app version, fix close stream bug, show audiobook progress, add toasts 2021-09-04 12:31:00 -05:00
advplyr 98076927ff Update android icons 2021-09-03 15:48:49 -05:00
advplyrandadvplyr fda8e9a07a Ios 2021-09-03 14:41:00 -05:00
advplyr 06657538a9 Set ver, clean up dependencies 2021-09-02 12:19:26 -05:00
advplyr 495af35043 init 2021-09-01 20:07:11 -05:00
249 changed files with 16599 additions and 1463 deletions
+37
View File
@@ -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:
+18
View File
@@ -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.
+2 -1
View File
@@ -89,4 +89,5 @@ sw.*
# Vim swap files
*.swp
/resources/
/resources/
/android/app/release/
+175 -26
View File
@@ -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}`)
})
}
}
+16 -22
View File
@@ -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")
}
+7 -1
View File
@@ -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')
}
+39 -14
View File
@@ -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"
}
]
Binary file not shown.

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")
}
})
}
}
File diff suppressed because it is too large Load Diff
@@ -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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 462 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 444 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 199 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 335 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 355 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 289 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 598 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 430 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 448 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 B

Binary file not shown.

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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

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>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 393 B

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.6 KiB

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 564 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 536 B

After

Width:  |  Height:  |  Size: 1011 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 B

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

After

Width:  |  Height:  |  Size: 15 KiB

+2 -2
View File
@@ -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>
-9
View File
@@ -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>

Some files were not shown because too many files have changed in this diff Show More