first commit
12
.editorconfig
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 2
|
||||||
|
charset = utf-8
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.swift]
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
98
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
name: 🐞 ABS App Bug Report
|
||||||
|
description: File a bug/issue and help us improve the Audiobookshelf mobile apps.
|
||||||
|
title: '[Bug]: '
|
||||||
|
labels: ['bug', 'triage']
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: '## App Bug Description'
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: 'Thank you for filing a bug report! 🐛'
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: 'Join the [discord server](https://discord.gg/HQgCbd6E75) for questions or if you are not sure about a bug.'
|
||||||
|
- type: dropdown
|
||||||
|
id: confirm-check
|
||||||
|
attributes:
|
||||||
|
label: I have verified that the [bug is not already awaiting release](https://github.com/advplyr/audiobookshelf-app/issues?q=is%3Aissue%20label%3A%22awaiting%20release%22)
|
||||||
|
multiple: false
|
||||||
|
options:
|
||||||
|
- 'Yes'
|
||||||
|
- 'No'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: What was the Problem?
|
||||||
|
description: Please help us understand the problem.
|
||||||
|
placeholder: Describe the problem you saw or experienced in detail! Include a screenshot or video, if applicable.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: steps-to-reproduce
|
||||||
|
attributes:
|
||||||
|
label: Steps to Reproduce the Issue
|
||||||
|
description: Please help us understand how we can reliably reproduce the issue.
|
||||||
|
placeholder: '1. Go to the library page of a Podcast library and...'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: what-was-expected
|
||||||
|
attributes:
|
||||||
|
label: What was Expected?
|
||||||
|
description: Please help us understand what should have happened.
|
||||||
|
placeholder: Please help us visualize by including a doodle or screenshot.
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: '## Mobile Environment'
|
||||||
|
- type: input
|
||||||
|
id: phone-model
|
||||||
|
attributes:
|
||||||
|
label: Phone Model
|
||||||
|
description: What kind of phone are you using?
|
||||||
|
placeholder: e.g. Pixel 6, iPhone 14, Samsung Galaxy s23, etc
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: input
|
||||||
|
id: phone-os
|
||||||
|
attributes:
|
||||||
|
label: Phone OS
|
||||||
|
description: What operating system? If Android, is it stock or a custom system?
|
||||||
|
placeholder: e.g. Android 14, iOS 17.4, etc
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: app-version
|
||||||
|
attributes:
|
||||||
|
label: Audiobookshelf App Version
|
||||||
|
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 'Android App - 0.14.1'
|
||||||
|
- 'iOS App - 0.14.1'
|
||||||
|
- 'Android App - 0.14.0'
|
||||||
|
- 'iOS App - 0.14.0'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: install-source
|
||||||
|
attributes:
|
||||||
|
label: Installation Source
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 'Google Play Store'
|
||||||
|
- 'Testflight'
|
||||||
|
- 'SideStore'
|
||||||
|
- 'Other (List in "Additional Notes")'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: additional-notes
|
||||||
|
attributes:
|
||||||
|
label: Additional Notes
|
||||||
|
description: Anything else you want to add?
|
||||||
|
placeholder: 'e.g. I have tried X, Y, and Z.'
|
||||||
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
blank_issues_enabled: false
|
||||||
|
contact_links:
|
||||||
|
- name: Discord
|
||||||
|
url: https://discord.gg/HQgCbd6E75
|
||||||
|
about: Ask questions, get help troubleshooting, and join the Abs community here.
|
||||||
57
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
name: 🚀 App Feature Request
|
||||||
|
description: Request a feature/enhancement
|
||||||
|
title: '[Enhancement]: '
|
||||||
|
labels: ['enhancement']
|
||||||
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: '## App Feature Request Description'
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: 'Please first search in both issues & discussions for your enhancement and make sure your app is up to date.'
|
||||||
|
- type: textarea
|
||||||
|
id: describe
|
||||||
|
attributes:
|
||||||
|
label: Describe the Feature/Enhancement
|
||||||
|
description: Please help us understand what you want.
|
||||||
|
placeholder: What is your vision?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: the-why
|
||||||
|
attributes:
|
||||||
|
label: Why would this be helpful?
|
||||||
|
description: Please help us understand why this would enhance your experience.
|
||||||
|
placeholder: Explain the "why" or "use case".
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: image
|
||||||
|
attributes:
|
||||||
|
label: Future Implementation (Screenshot)
|
||||||
|
description: Please help us visualize by including a doodle or screenshot.
|
||||||
|
placeholder: How could this look or where could it go?
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: '## App Current Implementation'
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Audiobookshelf App Version
|
||||||
|
description: Please ensure your app is up to date. *If you are using a 3rd-party app, please reach out to them directly.*
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 'Android App - 0.14.1'
|
||||||
|
- 'iOS App - 0.14.1'
|
||||||
|
- 'Android App - 0.14.0'
|
||||||
|
- 'iOS App - 0.14.0'
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: current-image
|
||||||
|
attributes:
|
||||||
|
label: Current Implementation (Screenshot)
|
||||||
|
description: What screen were you looking at when you thought of this enhancement?
|
||||||
|
placeholder: If an image is not applicable, please explain why.
|
||||||
41
.github/pull_request_template.md
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<!--
|
||||||
|
For Work In Progress Pull Requests, please use the Draft PR feature,
|
||||||
|
see https://github.blog/2019-02-14-introducing-draft-pull-requests/ for further details.
|
||||||
|
|
||||||
|
If you do not follow this template, the PR may be closed without review.
|
||||||
|
|
||||||
|
Please ensure all checks pass.
|
||||||
|
If you are a new contributor, the workflows will need to be manually approved before they run.
|
||||||
|
-->
|
||||||
|
|
||||||
|
## Brief summary
|
||||||
|
|
||||||
|
<!-- Please provide a brief summary of what your PR attempts to achieve. -->
|
||||||
|
|
||||||
|
## Which issue is fixed?
|
||||||
|
|
||||||
|
<!-- Which issue number does this PR fix? Ex: "Fixes #1234" -->
|
||||||
|
|
||||||
|
## Pull Request Type
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Does this affect only Android, only iOS, or both?
|
||||||
|
Does this change the frontend or the backend of the apps?
|
||||||
|
-->
|
||||||
|
|
||||||
|
## In-depth Description
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Describe your solution in more depth.
|
||||||
|
How does it work? Why is this the best solution?
|
||||||
|
Does it solve a problem that affects multiple users or is this an edge case for your setup?
|
||||||
|
-->
|
||||||
|
|
||||||
|
## How have you tested this?
|
||||||
|
|
||||||
|
<!-- Please describe in detail with reproducible steps how you tested your changes. -->
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
<!-- If your PR includes any changes to the front-end, please include screenshots or a
|
||||||
|
short video from before and after your changes. -->
|
||||||
81
.github/testing-page-template.html
vendored
Normal file
|
|
@ -0,0 +1,81 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<head>
|
||||||
|
<meta http-equiv=content-type content="text/html; charset=utf-8" />
|
||||||
|
<meta name=viewport content="width=device-width, initial-scale=1">
|
||||||
|
<title>Audiobookshelf :: Testers</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: rgb(55, 56, 56);
|
||||||
|
font-family: Sans;
|
||||||
|
margin: 40px auto;
|
||||||
|
max-width: 500px;
|
||||||
|
color: #ddd;
|
||||||
|
text-align: center;
|
||||||
|
padding: 5px;
|
||||||
|
}
|
||||||
|
img {
|
||||||
|
width: 100px;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-weight: 100;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
#apk {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto auto;
|
||||||
|
justify-content: center;
|
||||||
|
column-gap: 15px;
|
||||||
|
text-align: left;
|
||||||
|
font-family: monospace;
|
||||||
|
background-color: #222;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<a href="https://audiobookshelf.org">
|
||||||
|
<img src=logo.png />
|
||||||
|
</a>
|
||||||
|
<h1>Audiobookshelf :: Testers</h1>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Here you can download the latest version of Audiobookshelf for Android for <b>testing</b>.
|
||||||
|
The app is automatically built for every new patch added to the project.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id=apk>
|
||||||
|
<div>Built on</div>
|
||||||
|
<div>__DATE__</div>
|
||||||
|
|
||||||
|
<div>Git commit</div>
|
||||||
|
<a href="https://github.com/advplyr/audiobookshelf-app/commit/__COMMIT__">__COMMIT__</a>
|
||||||
|
|
||||||
|
<div>Download</div>
|
||||||
|
<a href="__APK__">__APK__</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
Using this requires some technical knowledge to install the APK manually.
|
||||||
|
Regular users should use the releases published on the <a href="https://play.google.com/store/apps/details?id=com.audiobookshelf.app">Play Store</a>.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<p>Report bugs, request features, provide feedback, and contribute on <a href="https://github.com/advplyr/audiobookshelf-app/issues">GitHub</a></p>
|
||||||
|
<p>
|
||||||
|
<a href="https://github.com/advplyr/audiobookshelf-app/issues">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" width=32 height=32 viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
53
.github/workflows/build-apk.yml
vendored
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
name: Build APK
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- 'ios/**'
|
||||||
|
- 'readme.md'
|
||||||
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- 'ios/**'
|
||||||
|
- 'readme.md'
|
||||||
|
jobs:
|
||||||
|
main:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: checkout sources
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: use Node.js 20.x
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v2
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- name: install dependencies
|
||||||
|
run: npm ci --omit=optional
|
||||||
|
|
||||||
|
- name: build Nuxt project
|
||||||
|
run: npm run generate
|
||||||
|
|
||||||
|
- name: copy to Android project
|
||||||
|
run: npx cap sync
|
||||||
|
|
||||||
|
- name: build Android app
|
||||||
|
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||||
|
|
||||||
|
- name: rename apk
|
||||||
|
working-directory: android/app/build/outputs/apk/debug/
|
||||||
|
run: |
|
||||||
|
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||||
|
name="audiobookshelf-${build}.apk"
|
||||||
|
mv -v app-debug.apk "${name}"
|
||||||
|
|
||||||
|
- name: upload app
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: audiobookshelf-apk
|
||||||
|
path: android/app/build/outputs/apk/debug/*.apk
|
||||||
68
.github/workflows/build-ios.yml
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
name: Build iOS
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- 'android/**'
|
||||||
|
- '.github/workflows/build-apk.yml'
|
||||||
|
- '.github/workflows/deploy-apk.yml'
|
||||||
|
- 'readme.md'
|
||||||
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- 'android/**'
|
||||||
|
- '.github/workflows/build-apk.yml'
|
||||||
|
- '.github/workflows/deploy-apk.yml'
|
||||||
|
- 'readme.md'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build iOS simulator app
|
||||||
|
runs-on: macos-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out sources
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 24
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install JavaScript dependencies
|
||||||
|
run: npm ci --omit=optional
|
||||||
|
|
||||||
|
- name: Build shared web app
|
||||||
|
run: npm run generate
|
||||||
|
|
||||||
|
- name: Sync Capacitor iOS project
|
||||||
|
run: npx cap sync ios
|
||||||
|
|
||||||
|
- name: Install CocoaPods dependencies
|
||||||
|
working-directory: ios/App
|
||||||
|
run: pod install --deployment
|
||||||
|
|
||||||
|
- name: Build iOS simulator app
|
||||||
|
working-directory: ios/App
|
||||||
|
run: |
|
||||||
|
xcodebuild -quiet \
|
||||||
|
-workspace App.xcworkspace \
|
||||||
|
-scheme App \
|
||||||
|
-configuration Debug \
|
||||||
|
-sdk iphonesimulator \
|
||||||
|
-destination 'generic/platform=iOS Simulator' \
|
||||||
|
-derivedDataPath build \
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME=IconsDebug \
|
||||||
|
CODE_SIGNING_ALLOWED=NO \
|
||||||
|
build
|
||||||
|
|
||||||
|
echo "iOS simulator build succeeded."
|
||||||
|
|
||||||
|
- name: Upload iOS simulator app
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: audiobookshelf-ios-simulator
|
||||||
|
path: ios/App/build/Build/Products/Debug-iphonesimulator/Audiobookshelf.app
|
||||||
|
if-no-files-found: error
|
||||||
20
.github/workflows/close-issues-on-release.yml
vendored
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
name: Close fixed issues on release.
|
||||||
|
on:
|
||||||
|
release:
|
||||||
|
types: [published]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
comment:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Close issues marked as fixed upon a release.
|
||||||
|
uses: gcampbell-msft/fixed-pending-release@7fa1b75a0c04bcd4b375110522878e5f6100cff5
|
||||||
|
with:
|
||||||
|
label: 'awaiting release'
|
||||||
|
removeLabel: true
|
||||||
|
applyToAll: true
|
||||||
|
message: Fixed in [${releaseTag}](${releaseUrl}).
|
||||||
42
.github/workflows/close_blank_issues.yaml
vendored
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
name: Close Issues not using a template
|
||||||
|
|
||||||
|
on:
|
||||||
|
issues:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
close_issue:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check issue headings
|
||||||
|
uses: actions/github-script@v6
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const issueBody = context.payload.issue.body || "";
|
||||||
|
|
||||||
|
// Match Markdown headings (e.g., # Heading, ## Heading)
|
||||||
|
const headingRegex = /^(#{1,6})\s.+/gm;
|
||||||
|
const headings = [...issueBody.matchAll(headingRegex)];
|
||||||
|
|
||||||
|
if (headings.length < 3) {
|
||||||
|
// Post a comment
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
body: "Thank you for opening an issue! To help us review your request efficiently, please use one of the provided issue templates. If you're seeking information or have a general question, consider opening a Discussion or joining the conversation on our Discord. Thanks!"
|
||||||
|
});
|
||||||
|
|
||||||
|
// Close the issue
|
||||||
|
await github.rest.issues.update({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.payload.issue.number,
|
||||||
|
state: "closed"
|
||||||
|
});
|
||||||
|
}
|
||||||
82
.github/workflows/deploy-apk.yml
vendored
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
name: Publish Test App
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
paths-ignore:
|
||||||
|
- 'ios/**'
|
||||||
|
- 'readme.md'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: checkout sources
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: use Node.js 20.x
|
||||||
|
uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Set up Java
|
||||||
|
uses: actions/setup-java@v2
|
||||||
|
with:
|
||||||
|
distribution: 'temurin'
|
||||||
|
java-version: 21
|
||||||
|
|
||||||
|
- name: install dependencies
|
||||||
|
run: npm ci --omit=optional
|
||||||
|
|
||||||
|
- name: build Nuxt project
|
||||||
|
run: npm run generate
|
||||||
|
|
||||||
|
- name: copy to Android project
|
||||||
|
run: npx cap sync
|
||||||
|
|
||||||
|
- name: build Android app
|
||||||
|
run: ./android/gradlew assembleDebug -p android --no-daemon
|
||||||
|
|
||||||
|
- name: rename apk
|
||||||
|
working-directory: android/app/build/outputs/apk/debug/
|
||||||
|
run: |
|
||||||
|
build="$(date +%Y%m%d-%H%M%S)-$(git rev-parse --short HEAD)"
|
||||||
|
name="audiobookshelf-${build}.apk"
|
||||||
|
mv -v app-debug.apk "${name}"
|
||||||
|
|
||||||
|
- name: prepare test page ressources
|
||||||
|
run: |
|
||||||
|
mkdir ghpages
|
||||||
|
cp android/app/build/outputs/apk/debug/*apk ghpages/
|
||||||
|
cp static/Logo.png ghpages/logo.png
|
||||||
|
cp .github/testing-page-template.html ghpages/index.html
|
||||||
|
|
||||||
|
- name: build test page
|
||||||
|
working-directory: ghpages
|
||||||
|
run: |
|
||||||
|
sed -i "s/__DATE__/$(date)/g" index.html
|
||||||
|
sed -i "s/__COMMIT__/$(git rev-parse --short HEAD)/g" index.html
|
||||||
|
sed -i "s/__APK__/$(ls *apk)/g" index.html
|
||||||
|
|
||||||
|
- name: upload test page artifact
|
||||||
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
with:
|
||||||
|
path: ./ghpages
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
pages: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
environment:
|
||||||
|
name: github-pages
|
||||||
|
url: ${{ steps.deployment.outputs.page_url }}
|
||||||
|
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: deploy to GitHub Pages
|
||||||
|
id: deployment
|
||||||
|
uses: actions/deploy-pages@v4
|
||||||
28
.github/workflows/i18n-check.yml
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
name: Verify all i18n files are alphabetized
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- strings/** # Should only check if any strings changed
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update_translations:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Check out the repository
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Set up node to run the javascript
|
||||||
|
- name: Set up node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
# The only argument is the `directory`, which is where the i18n files are
|
||||||
|
# stored.
|
||||||
|
- name: Run Update JSON Files action
|
||||||
|
uses: audiobookshelf/audiobookshelf-i18n-updater@v1.3.0
|
||||||
|
with:
|
||||||
|
directory: 'strings/' # Adjust the directory path as needed
|
||||||
25
.github/workflows/translate-credits.yml
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
name: Prepare translation credits
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release-notes:
|
||||||
|
if: github.event_name == 'workflow_dispatch' || contains(github.event.head_commit.message, 'weblate')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- id: translator-credits
|
||||||
|
uses: audiobookshelf/get-translator-credits@v1.1.0
|
||||||
|
|
||||||
|
- name: Show translator credits
|
||||||
|
run: |
|
||||||
|
printf '%s\n' "${{ steps.translator-credits.outputs.credits }}"
|
||||||
93
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# Created by .ignore support plugin (hsz.mobi)
|
||||||
|
### Node template
|
||||||
|
# Logs
|
||||||
|
/logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# Runtime data
|
||||||
|
pids
|
||||||
|
*.pid
|
||||||
|
*.seed
|
||||||
|
*.pid.lock
|
||||||
|
|
||||||
|
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||||
|
lib-cov
|
||||||
|
|
||||||
|
# Coverage directory used by tools like istanbul
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# nyc test coverage
|
||||||
|
.nyc_output
|
||||||
|
|
||||||
|
# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
|
||||||
|
.grunt
|
||||||
|
|
||||||
|
# Bower dependency directory (https://bower.io/)
|
||||||
|
bower_components
|
||||||
|
|
||||||
|
# node-waf configuration
|
||||||
|
.lock-wscript
|
||||||
|
|
||||||
|
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||||
|
build/Release
|
||||||
|
|
||||||
|
# Dependency directories
|
||||||
|
node_modules/
|
||||||
|
jspm_packages/
|
||||||
|
|
||||||
|
# TypeScript v1 declaration files
|
||||||
|
typings/
|
||||||
|
|
||||||
|
# Optional npm cache directory
|
||||||
|
.npm
|
||||||
|
|
||||||
|
# Optional eslint cache
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Optional REPL history
|
||||||
|
.node_repl_history
|
||||||
|
|
||||||
|
# Output of 'npm pack'
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# Yarn Integrity file
|
||||||
|
.yarn-integrity
|
||||||
|
|
||||||
|
# dotenv environment variables file
|
||||||
|
.env
|
||||||
|
|
||||||
|
# parcel-bundler cache (https://parceljs.org/)
|
||||||
|
.cache
|
||||||
|
|
||||||
|
# next.js build output
|
||||||
|
.next
|
||||||
|
|
||||||
|
# nuxt.js build output
|
||||||
|
.nuxt
|
||||||
|
|
||||||
|
# Nuxt generate
|
||||||
|
dist
|
||||||
|
|
||||||
|
# vuepress build output
|
||||||
|
.vuepress/dist
|
||||||
|
|
||||||
|
# Serverless directories
|
||||||
|
.serverless
|
||||||
|
|
||||||
|
# IDE / Editor
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Service worker
|
||||||
|
sw.*
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Vim swap files
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
/resources/
|
||||||
|
/android/app/release/
|
||||||
17
.prettierrc
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 400,
|
||||||
|
"proseWrap": "never",
|
||||||
|
"trailingComma": "none",
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["*.html"],
|
||||||
|
"options": {
|
||||||
|
"singleQuote": false,
|
||||||
|
"wrapAttributes": false,
|
||||||
|
"sortAttributes": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
7
.vscode/extensions.json
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"esbenp.prettier-vscode",
|
||||||
|
"octref.vetur"
|
||||||
|
]
|
||||||
|
}
|
||||||
27
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
{
|
||||||
|
"vetur.format.defaultFormatterOptions": {
|
||||||
|
"prettier": {
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 400,
|
||||||
|
"proseWrap": "never",
|
||||||
|
"trailingComma": "none"
|
||||||
|
},
|
||||||
|
"prettyhtml": {
|
||||||
|
"printWidth": 400,
|
||||||
|
"singleQuote": false,
|
||||||
|
"wrapAttributes": false,
|
||||||
|
"sortAttributes": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"editor.formatOnSave": true,
|
||||||
|
"editor.detectIndentation": true,
|
||||||
|
"editor.tabSize": 2,
|
||||||
|
"javascript.format.semicolons": "remove",
|
||||||
|
"[javascript][json][jsonc]": {
|
||||||
|
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||||
|
},
|
||||||
|
"[vue]": {
|
||||||
|
"editor.defaultFormatter": "octref.vetur"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
INSTALL.md
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
git clone https://github.com/advplyr/audiobookshelf-app.git
|
||||||
|
cd audiobookshelf-app
|
||||||
|
npm install
|
||||||
|
npm run generate
|
||||||
|
|
||||||
|
ls dist
|
||||||
|
index.html
|
||||||
|
_nuxt/
|
||||||
|
icons/
|
||||||
|
manifest.json
|
||||||
|
|
||||||
|
npx cap sync android
|
||||||
|
|
||||||
|
verifica dove é sdk
|
||||||
|
|
||||||
|
sudo find ~/Library/Android -type d -name "sdk" 2>/dev/null
|
||||||
|
/Users/fabio/Library/Android/sdk
|
||||||
|
|
||||||
|
installa java21 su macos
|
||||||
|
brew install --cask temurin@21
|
||||||
|
|
||||||
|
java -version
|
||||||
|
|
||||||
|
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
|
||||||
|
export PATH="$JAVA_HOME/bin:$PATH"
|
||||||
|
cd android
|
||||||
|
./gradlew assembleDebug
|
||||||
|
|
||||||
|
ls android/app/build/outputs/apk/debug/app-debug.apk
|
||||||
674
LICENSE
Normal file
|
|
@ -0,0 +1,674 @@
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
Audiobookshelf Self-hosted audiobook server
|
||||||
|
Copyright (C) 2021 advplyr
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
Audiobookshelf Copyright (C) 2021 advplyr
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
96
android/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
|
||||||
|
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
# Uncomment the following line in case you need and you don't have the release build type files in your app
|
||||||
|
# release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/workspace.xml
|
||||||
|
.idea/tasks.xml
|
||||||
|
.idea/gradle.xml
|
||||||
|
.idea/assetWizardSettings.xml
|
||||||
|
.idea/dictionaries
|
||||||
|
.idea/libraries
|
||||||
|
# Android Studio 3 in .gitignore file.
|
||||||
|
.idea/caches
|
||||||
|
.idea/modules.xml
|
||||||
|
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
|
||||||
|
.idea/navEditor.xml
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
# google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Cordova plugins for Capacitor
|
||||||
|
capacitor-cordova-android-plugins
|
||||||
|
|
||||||
|
# Copied web assets
|
||||||
|
app/src/main/assets/public
|
||||||
6
android/.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"java.project.root": "android",
|
||||||
|
"java.import.gradle.enabled": true,
|
||||||
|
"java.import.gradle.wrapper.enabled": true,
|
||||||
|
"java.import.gradle.user.home": "${workspaceFolder}/.gradle"
|
||||||
|
}
|
||||||
2
android/app/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/build/*
|
||||||
|
!/build/.npmkeep
|
||||||
133
android/app/build.gradle
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
plugins {
|
||||||
|
id 'com.android.application'
|
||||||
|
id 'kotlin-android'
|
||||||
|
id 'kotlin-kapt'
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
kotlinDaemonJvmArgs = [
|
||||||
|
"-Dfile.encoding=UTF-8",
|
||||||
|
"--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED",
|
||||||
|
"--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED",
|
||||||
|
"--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED",
|
||||||
|
"--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED",
|
||||||
|
"--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED",
|
||||||
|
"--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED",
|
||||||
|
"--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED",
|
||||||
|
"--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED",
|
||||||
|
"--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED",
|
||||||
|
"--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED"
|
||||||
|
]
|
||||||
|
|
||||||
|
jvmToolchain(17)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace 'com.audiobookshelf.app'
|
||||||
|
buildFeatures {
|
||||||
|
viewBinding true
|
||||||
|
buildConfig true
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
freeCompilerArgs = ['-Xjvm-default=all']
|
||||||
|
}
|
||||||
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
|
defaultConfig {
|
||||||
|
applicationId "com.audiobookshelf.app"
|
||||||
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
|
versionCode 120
|
||||||
|
versionName "0.14.1-beta"
|
||||||
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
manifestPlaceholders = [
|
||||||
|
"appAuthRedirectScheme": "com.audiobookshelf.app"
|
||||||
|
]
|
||||||
|
aaptOptions {
|
||||||
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
|
||||||
|
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
applicationIdSuffix ".debug"
|
||||||
|
versionNameSuffix "-debug"
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
release {
|
||||||
|
minifyEnabled false
|
||||||
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
flatDir {
|
||||||
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
configurations.configureEach {
|
||||||
|
resolutionStrategy {
|
||||||
|
force("com.google.android.gms:play-services-base:18.0.1")
|
||||||
|
force("androidx.appcompat:appcompat:$androidxAppCompatVersion")
|
||||||
|
force("androidx.core:core-ktx:$androidx_core_ktx_version")
|
||||||
|
force("androidx.media:media:$androidx_media_version")
|
||||||
|
force("org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version")
|
||||||
|
force("org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
||||||
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
|
implementation project(':capacitor-android')
|
||||||
|
|
||||||
|
implementation 'androidx.constraintlayout:constraintlayout:2.2.1'
|
||||||
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
|
|
||||||
|
implementation project(':capacitor-cordova-android-plugins')
|
||||||
|
|
||||||
|
implementation "androidx.core:core-ktx:$androidx_core_ktx_version"
|
||||||
|
implementation "androidx.work:work-runtime-ktx:2.9.1"
|
||||||
|
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$kotlin_coroutines_version"
|
||||||
|
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlin_coroutines_version"
|
||||||
|
|
||||||
|
implementation "androidx.media:media:$androidx_media_version"
|
||||||
|
|
||||||
|
// Exo Player
|
||||||
|
if (findProject(':exoplayer-library-core') != null) {
|
||||||
|
implementation project(':exoplayer-library-core')
|
||||||
|
implementation project(':exoplayer-library-ui')
|
||||||
|
implementation project(':exoplayer-extension-mediasession')
|
||||||
|
implementation project(':exoplayer-extension-cast')
|
||||||
|
implementation project(':exoplayer-hls')
|
||||||
|
} else {
|
||||||
|
implementation "com.google.android.exoplayer:exoplayer-core:$exoplayer_version"
|
||||||
|
implementation "com.google.android.exoplayer:exoplayer-ui:$exoplayer_version"
|
||||||
|
implementation "com.google.android.exoplayer:extension-mediasession:$exoplayer_version"
|
||||||
|
implementation "com.google.android.exoplayer:extension-cast:$exoplayer_version"
|
||||||
|
implementation "com.google.android.exoplayer:exoplayer-hls:$exoplayer_version"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Glide for images
|
||||||
|
implementation "com.github.bumptech.glide:glide:$glide_version"
|
||||||
|
|
||||||
|
// Paper NoSQL Db
|
||||||
|
implementation 'io.github.pilgr:paperdb:2.7.2'
|
||||||
|
|
||||||
|
// Simple Storage
|
||||||
|
implementation "com.anggrayudi:storage:1.5.6"
|
||||||
|
|
||||||
|
// OK HTTP
|
||||||
|
implementation 'com.squareup.okhttp3:okhttp:4.9.2'
|
||||||
|
|
||||||
|
// Jackson for JSON
|
||||||
|
implementation 'com.fasterxml.jackson.module:jackson-module-kotlin:2.12.2'
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: 'capacitor.build.gradle'
|
||||||
29
android/app/capacitor.build.gradle
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
|
||||||
|
|
||||||
|
android {
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||||
|
dependencies {
|
||||||
|
implementation project(':webnativellc-capacitor-filesharer')
|
||||||
|
implementation project(':capacitor-community-keep-awake')
|
||||||
|
implementation project(':capacitor-community-volume-buttons')
|
||||||
|
implementation project(':capacitor-app')
|
||||||
|
implementation project(':capacitor-browser')
|
||||||
|
implementation project(':capacitor-clipboard')
|
||||||
|
implementation project(':capacitor-dialog')
|
||||||
|
implementation project(':capacitor-haptics')
|
||||||
|
implementation project(':capacitor-network')
|
||||||
|
implementation project(':capacitor-preferences')
|
||||||
|
implementation project(':capacitor-status-bar')
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
if (hasProperty('postBuildExtras')) {
|
||||||
|
postBuildExtras()
|
||||||
|
}
|
||||||
21
android/app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
# You can control the set of applied configuration files using the
|
||||||
|
# proguardFiles setting in build.gradle.
|
||||||
|
#
|
||||||
|
# For more details, see
|
||||||
|
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||||
|
|
||||||
|
# If your project uses WebView with JS, uncomment the following
|
||||||
|
# and specify the fully qualified class name to the JavaScript interface
|
||||||
|
# class:
|
||||||
|
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
|
||||||
|
# public *;
|
||||||
|
#}
|
||||||
|
|
||||||
|
# Uncomment this to preserve the line number information for
|
||||||
|
# debugging stack traces.
|
||||||
|
#-keepattributes SourceFile,LineNumberTable
|
||||||
|
|
||||||
|
# If you keep the line number information, uncomment this to
|
||||||
|
# hide the original source file name.
|
||||||
|
#-renamesourcefileattribute SourceFile
|
||||||
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.getcapacitor.myapp;
|
||||||
|
|
||||||
|
import static org.junit.Assert.*;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instrumented test, which will execute on an Android device.
|
||||||
|
*
|
||||||
|
* @see <a href="http://d.android.com/tools/testing">Testing documentation</a>
|
||||||
|
*/
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
public class ExampleInstrumentedTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void useAppContext() throws Exception {
|
||||||
|
// Context of the app under test.
|
||||||
|
Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext();
|
||||||
|
|
||||||
|
assertEquals("com.getcapacitor.app", appContext.getPackageName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@mipmap/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
<monochrome android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
BIN
android/app/src/debug/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
android/app/src/debug/res/mipmap-hdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 879 B |
BIN
android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.9 KiB |
BIN
android/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
android/app/src/debug/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/debug/res/mipmap-mdpi/ic_launcher_background.png
Normal file
|
After Width: | Height: | Size: 685 B |
BIN
android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 1.3 KiB |
BIN
android/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
BIN
android/app/src/debug/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
|
After Width: | Height: | Size: 1 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
BIN
android/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 8 KiB |
BIN
android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
BIN
android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
BIN
android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
14
android/app/src/debug/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Audiobookshelf</string>
|
||||||
|
<string name="title_activity_main">Audiobookshelf</string>
|
||||||
|
<string name="package_name">com.audiobookshelf.app</string>
|
||||||
|
<string name="custom_url_scheme">audiobookshelf</string>
|
||||||
|
<string name="add_widget">Add widget</string>
|
||||||
|
<string name="app_widget_description">Simple widget for audiobookshelf playback</string>
|
||||||
|
<string name="action_jump_forward">Jump Forward</string>
|
||||||
|
<string name="action_jump_backward">Jump Backward</string>
|
||||||
|
<string name="action_skip_forward">Skip Forward</string>
|
||||||
|
<string name="action_skip_backward">Skip Backward</string>
|
||||||
|
<string name="action_change_speed">Change Playback Speed</string>
|
||||||
|
</resources>
|
||||||
120
android/app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
<?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"
|
||||||
|
android:installLocation="preferExternal" >
|
||||||
|
|
||||||
|
<!-- Permissions -->
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="28" />
|
||||||
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
|
android:requestLegacyExternalStorage="true"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/AppTheme"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:largeHeap="true" >
|
||||||
|
|
||||||
|
<!-- Android app widget -->
|
||||||
|
<receiver
|
||||||
|
android:name="MediaPlayerWidget"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
|
||||||
|
</intent-filter>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="android.appwidget.provider"
|
||||||
|
android:resource="@xml/media_player_widget_info" />
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.samsung.android.appwidget.provider"
|
||||||
|
android:resource="@xml/samsung_cover_widget_info" />
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<!-- 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:name=".MainActivity"
|
||||||
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode|navigation"
|
||||||
|
android:exported="true"
|
||||||
|
android:label="@string/title_activity_main"
|
||||||
|
android:launchMode="singleTask"
|
||||||
|
android:theme="@style/AppTheme.NoActionBarLaunch" >
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<receiver android:name="androidx.media.session.MediaButtonReceiver" android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".player.PlayerNotificationService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="true"
|
||||||
|
android:foregroundServiceType="mediaPlayback">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.media.browse.MediaBrowserService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".services.DownloadService"
|
||||||
|
android:enabled="true"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="dataSync" />
|
||||||
|
|
||||||
|
<provider
|
||||||
|
android:name="androidx.core.content.FileProvider"
|
||||||
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
android:grantUriPermissions="true"
|
||||||
|
android:exported="false">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
|
android:resource="@xml/file_paths" />
|
||||||
|
</provider>
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
17
android/app/src/main/assets/capacitor.config.json
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
{
|
||||||
|
"appId": "com.audiobookshelf.app",
|
||||||
|
"appName": "audiobookshelf-app",
|
||||||
|
"webDir": "dist",
|
||||||
|
"plugins": {
|
||||||
|
"CapacitorHttp": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"StatusBar": {
|
||||||
|
"backgroundColor": "#232323",
|
||||||
|
"style": "DARK"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"server": {
|
||||||
|
"androidScheme": "http"
|
||||||
|
}
|
||||||
|
}
|
||||||
46
android/app/src/main/assets/capacitor.plugins.json
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"pkg": "@webnativellc/capacitor-filesharer",
|
||||||
|
"classpath": "com.byteowls.capacitor.filesharer.FileSharerPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor-community/keep-awake",
|
||||||
|
"classpath": "com.getcapacitor.community.keepawake.KeepAwakePlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor-community/volume-buttons",
|
||||||
|
"classpath": "com.ryltsov.alex.plugins.volume.buttons.VolumeButtonsPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/app",
|
||||||
|
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/browser",
|
||||||
|
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/clipboard",
|
||||||
|
"classpath": "com.capacitorjs.plugins.clipboard.ClipboardPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/dialog",
|
||||||
|
"classpath": "com.capacitorjs.plugins.dialog.DialogPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/haptics",
|
||||||
|
"classpath": "com.capacitorjs.plugins.haptics.HapticsPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/network",
|
||||||
|
"classpath": "com.capacitorjs.plugins.network.NetworkPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/preferences",
|
||||||
|
"classpath": "com.capacitorjs.plugins.preferences.PreferencesPlugin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pkg": "@capacitor/status-bar",
|
||||||
|
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
android/app/src/main/ic_launcher-playstore.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -0,0 +1,30 @@
|
||||||
|
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")
|
||||||
|
var appId = "FD1F76C5"
|
||||||
|
// var defaultId =CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID
|
||||||
|
return CastOptions.Builder()
|
||||||
|
.setReceiverApplicationId(appId).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
|
||||||
|
}
|
||||||
|
}
|
||||||
185
android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
package com.audiobookshelf.app
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.view.WindowInsets
|
||||||
|
import android.webkit.WebView
|
||||||
|
import androidx.core.app.ActivityCompat
|
||||||
|
import androidx.core.view.updateLayoutParams
|
||||||
|
import com.anggrayudi.storage.SimpleStorage
|
||||||
|
import com.anggrayudi.storage.SimpleStorageHelper
|
||||||
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
import com.audiobookshelf.app.plugins.AbsAudioPlayer
|
||||||
|
import com.audiobookshelf.app.plugins.AbsDatabase
|
||||||
|
import com.audiobookshelf.app.plugins.AbsDownloader
|
||||||
|
import com.audiobookshelf.app.plugins.AbsFileSystem
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
|
import com.getcapacitor.BridgeActivity
|
||||||
|
|
||||||
|
|
||||||
|
class MainActivity : BridgeActivity() {
|
||||||
|
private val tag = "MainActivity"
|
||||||
|
|
||||||
|
private var mBounded = false
|
||||||
|
lateinit var foregroundService : PlayerNotificationService
|
||||||
|
private lateinit var mConnection : ServiceConnection
|
||||||
|
|
||||||
|
lateinit var pluginCallback : () -> Unit
|
||||||
|
|
||||||
|
val storageHelper = SimpleStorageHelper(this)
|
||||||
|
val storage = SimpleStorage(this)
|
||||||
|
|
||||||
|
val REQUEST_PERMISSIONS = 1
|
||||||
|
|
||||||
|
public override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
DbManager.initialize(applicationContext)
|
||||||
|
|
||||||
|
registerPlugin(AbsAudioPlayer::class.java)
|
||||||
|
registerPlugin(AbsDownloader::class.java)
|
||||||
|
registerPlugin(AbsFileSystem::class.java)
|
||||||
|
registerPlugin(AbsDatabase::class.java)
|
||||||
|
registerPlugin(AbsLogger::class.java)
|
||||||
|
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
Log.d(tag, "onCreate")
|
||||||
|
|
||||||
|
// Update the margins to handle edge-to-edge enforced in SDK 35
|
||||||
|
// See: https://developer.android.com/develop/ui/views/layout/edge-to-edge
|
||||||
|
val webView: WebView = findViewById(R.id.webview)
|
||||||
|
webView.setOnApplyWindowInsetsListener { v, insets ->
|
||||||
|
val (left, top, right, bottom) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
val sysInsets = insets.getInsets(WindowInsets.Type.systemBars())
|
||||||
|
Log.d(tag, "safe sysInsets: $sysInsets")
|
||||||
|
arrayOf(sysInsets.left, sysInsets.top, sysInsets.right, sysInsets.bottom)
|
||||||
|
} else {
|
||||||
|
arrayOf(
|
||||||
|
insets.systemWindowInsetLeft,
|
||||||
|
insets.systemWindowInsetTop,
|
||||||
|
insets.systemWindowInsetRight,
|
||||||
|
insets.systemWindowInsetBottom
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject as CSS variables
|
||||||
|
// NOTE: Possibly able to use in the future to support edge-to-edge better.
|
||||||
|
val js = """
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-top', '${top}px');
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-bottom', '${bottom}px');
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-left', '${left}px');
|
||||||
|
document.documentElement.style.setProperty('--safe-area-inset-right', '${right}px');
|
||||||
|
""".trimIndent()
|
||||||
|
webView.evaluateJavascript(js, null)
|
||||||
|
|
||||||
|
// Set margins
|
||||||
|
v.updateLayoutParams<ViewGroup.MarginLayoutParams> {
|
||||||
|
leftMargin = left
|
||||||
|
bottomMargin = bottom
|
||||||
|
rightMargin = right
|
||||||
|
topMargin = top
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
WindowInsets.CONSUMED
|
||||||
|
} else {
|
||||||
|
insets
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestNeededPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestNeededPermissions() {
|
||||||
|
val needed = mutableListOf<String>()
|
||||||
|
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
needed.add(Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
|
||||||
|
ActivityCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
needed.add(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
|
}
|
||||||
|
if (needed.isNotEmpty()) {
|
||||||
|
ActivityCompat.requestPermissions(this, needed.toTypedArray(), REQUEST_PERMISSIONS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPostCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onPostCreate(savedInstanceState)
|
||||||
|
Log.d(tag, "onPostCreate MainActivity")
|
||||||
|
|
||||||
|
mConnection = object : ServiceConnection {
|
||||||
|
override fun onServiceDisconnected(name: ComponentName) {
|
||||||
|
Log.w(tag, "Service Disconnected $name")
|
||||||
|
mBounded = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServiceConnected(name: ComponentName, service: IBinder) {
|
||||||
|
Log.d(tag, "Service Connected $name")
|
||||||
|
|
||||||
|
mBounded = true
|
||||||
|
val mLocalBinder = service as PlayerNotificationService.LocalBinder
|
||||||
|
foregroundService = mLocalBinder.getService()
|
||||||
|
|
||||||
|
// Let NativeAudio know foreground service is ready and setup event listener
|
||||||
|
pluginCallback()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Intent(this, PlayerNotificationService::class.java).also { intent ->
|
||||||
|
Log.d(tag, "Binding PlayerNotificationService")
|
||||||
|
bindService(intent, mConnection, Context.BIND_AUTO_CREATE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isPlayerNotificationServiceInitialized():Boolean {
|
||||||
|
return ::foregroundService.isInitialized
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopMyService() {
|
||||||
|
if (mBounded) {
|
||||||
|
mConnection.let { unbindService(it) };
|
||||||
|
mBounded = false;
|
||||||
|
}
|
||||||
|
val stopIntent = Intent(this, PlayerNotificationService::class.java)
|
||||||
|
stopService(stopIntent)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSaveInstanceState(outState: Bundle) {
|
||||||
|
storageHelper.onSaveInstanceState(outState)
|
||||||
|
super.onSaveInstanceState(outState)
|
||||||
|
outState.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
package com.audiobookshelf.app
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.appwidget.AppWidgetProvider
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
import androidx.media.session.MediaButtonReceiver
|
||||||
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.bumptech.glide.request.RequestOptions
|
||||||
|
import com.bumptech.glide.request.target.AppWidgetTarget
|
||||||
|
import com.bumptech.glide.request.transition.Transition
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of App Widget functionality.
|
||||||
|
*/
|
||||||
|
class MediaPlayerWidget : AppWidgetProvider() {
|
||||||
|
val tag = "MediaPlayerWidget"
|
||||||
|
override fun onUpdate(context: Context, appWidgetManager: AppWidgetManager, appWidgetIds: IntArray) {
|
||||||
|
Log.d(tag, "onUpdate $appWidgetIds")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEnabled(context: Context) {
|
||||||
|
Log.i(tag, "onEnabled check context ${context.packageName}")
|
||||||
|
|
||||||
|
DbManager.initialize(context)
|
||||||
|
|
||||||
|
DeviceManager.deviceData.lastPlaybackSession?.let {
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||||
|
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||||
|
Log.d(tag, "Setting initial widget state with last playback session ${it.displayTitle}")
|
||||||
|
for (widgetId in ids) {
|
||||||
|
updateAppWidget(context, appWidgetManager, widgetId, it, false, true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter relevant functionality for when the first widget is created
|
||||||
|
DeviceManager.initializeWidgetUpdater(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun updateAppWidget(context: Context, appWidgetManager: AppWidgetManager, appWidgetId: Int, playbackSession: PlaybackSession?, isPlaying:Boolean, isAppClosed:Boolean) {
|
||||||
|
val tag = "MediaPlayerWidget"
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.media_player_widget)
|
||||||
|
Log.i(tag, "updateAppWidget ${playbackSession?.displayTitle ?: "No Title"} isPlaying=$isPlaying isAppClosed=$isAppClosed")
|
||||||
|
val wholeWidgetClickI = Intent(context, MainActivity::class.java)
|
||||||
|
wholeWidgetClickI.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK
|
||||||
|
val wholeWidgetClickPI = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
System.currentTimeMillis().toInt(),
|
||||||
|
wholeWidgetClickI,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
val playPausePI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_PLAY_PAUSE)
|
||||||
|
views.setOnClickPendingIntent(R.id.widgetPlayPauseButton, playPausePI)
|
||||||
|
|
||||||
|
val fastForwardPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_FAST_FORWARD)
|
||||||
|
views.setOnClickPendingIntent(R.id.widgetFastForwardButton, fastForwardPI)
|
||||||
|
|
||||||
|
val rewindPI = MediaButtonReceiver.buildMediaButtonPendingIntent(context, PlaybackStateCompat.ACTION_REWIND)
|
||||||
|
views.setOnClickPendingIntent(R.id.widgetRewindButton, rewindPI)
|
||||||
|
|
||||||
|
// Show/Hide button container
|
||||||
|
views.setViewVisibility(R.id.widgetButtonContainer, if (isAppClosed) View.GONE else View.VISIBLE)
|
||||||
|
|
||||||
|
views.setOnClickPendingIntent(R.id.widgetBackground, wholeWidgetClickPI)
|
||||||
|
|
||||||
|
val imageUri = playbackSession?.getCoverUri(context) ?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
|
val awt: AppWidgetTarget = object : AppWidgetTarget(context.applicationContext, R.id.widgetAlbumArt, views, appWidgetId) {
|
||||||
|
override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
|
||||||
|
super.onResourceReady(resource, transition)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val artist = playbackSession?.displayAuthor ?: "Unknown"
|
||||||
|
views.setTextViewText(R.id.widgetArtistText, artist)
|
||||||
|
|
||||||
|
val title = playbackSession?.displayTitle ?: "Unknown"
|
||||||
|
views.setTextViewText(R.id.widgetMediaTitle, title)
|
||||||
|
|
||||||
|
val options = RequestOptions().override(300, 300).placeholder(R.drawable.icon).error(R.drawable.icon)
|
||||||
|
Glide.with(context.applicationContext).asBitmap().load(imageUri).apply(options).into(awt)
|
||||||
|
|
||||||
|
Log.i(tag, "Update App Widget | Is Playing=$isPlaying | isAppClosed=$isAppClosed")
|
||||||
|
|
||||||
|
val playPauseResource = if (isPlaying) androidx.mediarouter.R.drawable.ic_media_pause_dark else androidx.mediarouter.R.drawable.ic_media_play_dark
|
||||||
|
views.setImageViewResource(R.id.widgetPlayPauseButton, playPauseResource)
|
||||||
|
|
||||||
|
// Instruct the widget manager to update the widget
|
||||||
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class AudioTrack(
|
||||||
|
var index: Int,
|
||||||
|
var startOffset: Double,
|
||||||
|
var duration: Double,
|
||||||
|
var title: String,
|
||||||
|
var contentUrl: String,
|
||||||
|
var mimeType: String?,
|
||||||
|
var metadata: FileMetadata?,
|
||||||
|
var isLocal: Boolean,
|
||||||
|
var localFileId: String?,
|
||||||
|
// TODO: This should no longer be necessary
|
||||||
|
var serverIndex: Int? // Need to know if server track index is different
|
||||||
|
) {
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val startOffsetMs
|
||||||
|
get() = (startOffset * 1000L).toLong()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val durationMs
|
||||||
|
get() = (duration * 1000L).toLong()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val endOffsetMs
|
||||||
|
get() = startOffsetMs + durationMs
|
||||||
|
@get:JsonIgnore
|
||||||
|
val relPath
|
||||||
|
get() = metadata?.relPath ?: ""
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getBookChapter(): BookChapter {
|
||||||
|
return BookChapter(index + 1, startOffset, startOffset + duration, title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class CollapsedSeries(
|
||||||
|
id:String,
|
||||||
|
var libraryId:String?,
|
||||||
|
var name:String,
|
||||||
|
//var nameIgnorePrefix:String,
|
||||||
|
var sequence:String?,
|
||||||
|
var libraryItemIds:MutableList<String>
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = name
|
||||||
|
@get:JsonIgnore
|
||||||
|
val numBooks get() = libraryItemIds.size
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
|
||||||
|
val mediaId = "__LIBRARY__${libraryId}__SERIE__${id}"
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(title)
|
||||||
|
//.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle("${numBooks} books")
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,514 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.icu.text.DateFormat
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.audiobookshelf.app.media.MediaManager
|
||||||
|
import com.fasterxml.jackson.annotation.*
|
||||||
|
import com.audiobookshelf.app.media.getUriToAbsIconDrawable
|
||||||
|
import java.util.Date
|
||||||
|
|
||||||
|
// This auto-detects whether it is a Book or Podcast
|
||||||
|
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||||
|
@JsonSubTypes(
|
||||||
|
JsonSubTypes.Type(Book::class),
|
||||||
|
JsonSubTypes.Type(Podcast::class)
|
||||||
|
)
|
||||||
|
open class MediaType(var metadata:MediaTypeMetadata, var coverPath:String?) {
|
||||||
|
@JsonIgnore
|
||||||
|
open fun getAudioTracks():List<AudioTrack> { return mutableListOf() }
|
||||||
|
@JsonIgnore
|
||||||
|
open fun setAudioTracks(audioTracks:MutableList<AudioTrack>) { }
|
||||||
|
@JsonIgnore
|
||||||
|
open fun addAudioTrack(audioTrack:AudioTrack) { }
|
||||||
|
@JsonIgnore
|
||||||
|
open fun removeAudioTrack(localFileId:String) { }
|
||||||
|
@JsonIgnore
|
||||||
|
open fun getLocalCopy():MediaType { return MediaType(MediaTypeMetadata("", false),null) }
|
||||||
|
@JsonIgnore
|
||||||
|
open fun checkHasTracks():Boolean { return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class Podcast(
|
||||||
|
metadata:PodcastMetadata,
|
||||||
|
coverPath:String?,
|
||||||
|
var tags:MutableList<String>,
|
||||||
|
var episodes:MutableList<PodcastEpisode>?,
|
||||||
|
var autoDownloadEpisodes:Boolean,
|
||||||
|
var numEpisodes:Int?
|
||||||
|
) : MediaType(metadata, coverPath) {
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getAudioTracks():List<AudioTrack> {
|
||||||
|
val tracks = episodes?.map { it.audioTrack }
|
||||||
|
return tracks?.filterNotNull() ?: mutableListOf()
|
||||||
|
}
|
||||||
|
@JsonIgnore
|
||||||
|
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
||||||
|
// Remove episodes no longer there in tracks
|
||||||
|
episodes = episodes?.filter { ep ->
|
||||||
|
audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null
|
||||||
|
} as MutableList<PodcastEpisode>
|
||||||
|
|
||||||
|
// Add new episodes
|
||||||
|
audioTracks.forEach { at ->
|
||||||
|
if (episodes?.find{ it.audioTrack?.localFileId == at.localFileId } == null) {
|
||||||
|
val localEpisodeId = "local_ep_" + at.localFileId
|
||||||
|
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,at.title,null,null,null, null, null, at,null,at.duration,0, null, localEpisodeId)
|
||||||
|
episodes?.add(newEpisode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var index = 1
|
||||||
|
episodes?.forEach {
|
||||||
|
it.index = index
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@JsonIgnore
|
||||||
|
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||||
|
val localEpisodeId = "local_ep_" + audioTrack.localFileId
|
||||||
|
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,null,null,audioTrack.title,null,null,null, null, null,audioTrack,null,audioTrack.duration,0, null, localEpisodeId)
|
||||||
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
|
var index = 1
|
||||||
|
episodes?.forEach {
|
||||||
|
it.index = index
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@JsonIgnore
|
||||||
|
override fun removeAudioTrack(localFileId:String) {
|
||||||
|
episodes?.removeIf { it.audioTrack?.localFileId == localFileId }
|
||||||
|
|
||||||
|
var index = 1
|
||||||
|
episodes?.forEach {
|
||||||
|
it.index = index
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Used for FolderScanner local podcast item to get copy of Podcast excluding episodes
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getLocalCopy(): Podcast {
|
||||||
|
return Podcast(metadata as PodcastMetadata,coverPath,tags, mutableListOf(),autoDownloadEpisodes, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun checkHasTracks():Boolean {
|
||||||
|
return (episodes?.size ?: numEpisodes ?: 0) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun addEpisode(audioTrack:AudioTrack, episode:PodcastEpisode):PodcastEpisode {
|
||||||
|
val localEpisodeId = "local_ep_" + episode.id
|
||||||
|
val newEpisode = PodcastEpisode(localEpisodeId,(episodes?.size ?: 0) + 1,episode.episode,episode.episodeType,episode.title,episode.subtitle,episode.description,null,null,null,audioTrack,episode.chapters,audioTrack.duration,episode.size, episode.id, localEpisodeId)
|
||||||
|
episodes?.add(newEpisode)
|
||||||
|
|
||||||
|
var index = 1
|
||||||
|
episodes?.forEach {
|
||||||
|
it.index = index
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
return newEpisode
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getNextUnfinishedEpisode(libraryItemId:String, mediaManager: MediaManager):PodcastEpisode? {
|
||||||
|
val sortedEpisodes = episodes?.sortedByDescending { it.publishedAt }
|
||||||
|
val podcastEpisode = sortedEpisodes?.find { episode ->
|
||||||
|
val progress = mediaManager.serverUserMediaProgress.find { it.libraryItemId == libraryItemId && it.episodeId == episode.id }
|
||||||
|
progress == null || !progress.isFinished
|
||||||
|
}
|
||||||
|
return podcastEpisode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class Book(
|
||||||
|
metadata:BookMetadata,
|
||||||
|
coverPath:String?,
|
||||||
|
var tags:List<String>,
|
||||||
|
var audioFiles:List<AudioFile>?,
|
||||||
|
var chapters:List<BookChapter>?,
|
||||||
|
var tracks:MutableList<AudioTrack>?,
|
||||||
|
var ebookFile: EBookFile?,
|
||||||
|
var size:Long?,
|
||||||
|
var duration:Double?,
|
||||||
|
var numTracks:Int?
|
||||||
|
) : MediaType(metadata, coverPath) {
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getAudioTracks():List<AudioTrack> {
|
||||||
|
return tracks ?: mutableListOf()
|
||||||
|
}
|
||||||
|
@JsonIgnore
|
||||||
|
override fun setAudioTracks(audioTracks:MutableList<AudioTrack>) {
|
||||||
|
tracks = audioTracks
|
||||||
|
tracks?.sortBy { it.index }
|
||||||
|
|
||||||
|
var totalDuration = 0.0
|
||||||
|
var currentStartOffset = 0.0
|
||||||
|
tracks?.forEach {
|
||||||
|
totalDuration += it.duration
|
||||||
|
it.startOffset = currentStartOffset
|
||||||
|
currentStartOffset += it.duration
|
||||||
|
}
|
||||||
|
duration = totalDuration
|
||||||
|
}
|
||||||
|
@JsonIgnore
|
||||||
|
override fun addAudioTrack(audioTrack:AudioTrack) {
|
||||||
|
tracks?.add(audioTrack)
|
||||||
|
|
||||||
|
var totalDuration = 0.0
|
||||||
|
tracks?.forEach {
|
||||||
|
totalDuration += it.duration
|
||||||
|
}
|
||||||
|
duration = totalDuration
|
||||||
|
}
|
||||||
|
@JsonIgnore
|
||||||
|
override fun removeAudioTrack(localFileId:String) {
|
||||||
|
tracks?.removeIf { it.localFileId == localFileId }
|
||||||
|
|
||||||
|
tracks?.sortBy { it.index }
|
||||||
|
|
||||||
|
var index = 1
|
||||||
|
var startOffset = 0.0
|
||||||
|
var totalDuration = 0.0
|
||||||
|
tracks?.forEach {
|
||||||
|
it.index = index
|
||||||
|
it.startOffset = startOffset
|
||||||
|
totalDuration += it.duration
|
||||||
|
|
||||||
|
index++
|
||||||
|
startOffset += it.duration
|
||||||
|
}
|
||||||
|
duration = totalDuration
|
||||||
|
}
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getLocalCopy(): Book {
|
||||||
|
return Book(metadata as BookMetadata,coverPath,tags, mutableListOf(),chapters,mutableListOf(), ebookFile, null,null, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun checkHasTracks():Boolean {
|
||||||
|
return (tracks?.size ?: numTracks ?: 0) > 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This auto-detects whether it is a BookMetadata or PodcastMetadata
|
||||||
|
@JsonTypeInfo(use=JsonTypeInfo.Id.DEDUCTION)
|
||||||
|
@JsonSubTypes(
|
||||||
|
JsonSubTypes.Type(BookMetadata::class),
|
||||||
|
JsonSubTypes.Type(PodcastMetadata::class)
|
||||||
|
)
|
||||||
|
open class MediaTypeMetadata(var title:String, var explicit:Boolean) {
|
||||||
|
@JsonIgnore
|
||||||
|
open fun getAuthorDisplayName():String { return "Unknown" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class BookMetadata(
|
||||||
|
title:String,
|
||||||
|
var subtitle:String?,
|
||||||
|
var authors:MutableList<Author>?,
|
||||||
|
var narrators:MutableList<String>?,
|
||||||
|
var genres:MutableList<String>,
|
||||||
|
var publishedYear:String?,
|
||||||
|
var publishedDate:String?,
|
||||||
|
var publisher:String?,
|
||||||
|
var description:String?,
|
||||||
|
var isbn:String?,
|
||||||
|
var asin:String?,
|
||||||
|
var language:String?,
|
||||||
|
explicit:Boolean,
|
||||||
|
// In toJSONExpanded
|
||||||
|
var authorName:String?,
|
||||||
|
var authorNameLF:String?,
|
||||||
|
var narratorName:String?,
|
||||||
|
var seriesName:String?,
|
||||||
|
@JsonFormat(with=[JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY])
|
||||||
|
var series:List<SeriesType>?
|
||||||
|
) : MediaTypeMetadata(title, explicit) {
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getAuthorDisplayName():String { return authorName ?: "Unknown" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class PodcastMetadata(
|
||||||
|
title:String,
|
||||||
|
var author:String?,
|
||||||
|
var feedUrl:String?,
|
||||||
|
var genres:MutableList<String>,
|
||||||
|
explicit:Boolean
|
||||||
|
) : MediaTypeMetadata(title, explicit) {
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getAuthorDisplayName():String { return author ?: "Unknown" }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class Author(
|
||||||
|
var id:String,
|
||||||
|
var name:String,
|
||||||
|
var coverPath:String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class PodcastEpisode(
|
||||||
|
var id:String,
|
||||||
|
var index:Int,
|
||||||
|
var episode:String?,
|
||||||
|
var episodeType:String?,
|
||||||
|
var title:String?,
|
||||||
|
var subtitle:String?,
|
||||||
|
var description:String?,
|
||||||
|
var pubDate:String?,
|
||||||
|
var publishedAt:Long?,
|
||||||
|
var audioFile:AudioFile?,
|
||||||
|
var audioTrack:AudioTrack?,
|
||||||
|
var chapters:List<BookChapter>?,
|
||||||
|
var duration:Double?,
|
||||||
|
var size:Long?,
|
||||||
|
var serverEpisodeId:String?, // For local podcasts to match with server podcasts
|
||||||
|
var localEpisodeId:String? // For Android Auto server episodes with local copy
|
||||||
|
) {
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(libraryItem:LibraryItemWrapper, progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
|
val coverUri = if (libraryItem is LocalLibraryItem) {
|
||||||
|
libraryItem.getCoverUri(ctx)
|
||||||
|
} else {
|
||||||
|
(libraryItem as LibraryItem).getCoverUri()
|
||||||
|
}
|
||||||
|
|
||||||
|
val extras = Bundle()
|
||||||
|
|
||||||
|
if (localEpisodeId != null) {
|
||||||
|
extras.putLong(
|
||||||
|
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||||
|
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress != null) {
|
||||||
|
if (progress.isFinished) {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val libraryItemDescription = libraryItem.getMediaDescription(null, ctx)
|
||||||
|
val mediaId = localEpisodeId ?: id
|
||||||
|
var subtitle = libraryItemDescription.title
|
||||||
|
if (publishedAt !== null) {
|
||||||
|
val sdf = DateFormat.getDateInstance()
|
||||||
|
val publishedAtDT = Date(publishedAt!!)
|
||||||
|
subtitle = "${sdf.format(publishedAtDT)} / $subtitle"
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(coverUri)
|
||||||
|
.setSubtitle(subtitle)
|
||||||
|
.setExtras(extras)
|
||||||
|
|
||||||
|
libraryItemDescription.iconBitmap?.let {
|
||||||
|
mediaDescriptionBuilder.setIconBitmap(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaDescriptionBuilder.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LibraryFile(
|
||||||
|
var ino:String,
|
||||||
|
var metadata:FileMetadata
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class FileMetadata(
|
||||||
|
var filename:String,
|
||||||
|
var ext:String,
|
||||||
|
var path:String,
|
||||||
|
var relPath:String,
|
||||||
|
var size:Long?
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class AudioFile(
|
||||||
|
var index:Int,
|
||||||
|
var ino:String,
|
||||||
|
var metadata:FileMetadata
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class Library(
|
||||||
|
var id:String,
|
||||||
|
var name:String,
|
||||||
|
var folders:MutableList<Folder>,
|
||||||
|
var icon:String,
|
||||||
|
var mediaType:String,
|
||||||
|
var stats: LibraryStats?
|
||||||
|
) {
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaMetadata(context: Context, targetType: String? = null): MediaMetadataCompat {
|
||||||
|
var mediaId = id
|
||||||
|
if (targetType !== null) {
|
||||||
|
mediaId = "__RECENTLY__$id"
|
||||||
|
}
|
||||||
|
return MediaMetadataCompat.Builder().apply {
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, mediaId)
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, name)
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, name)
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToAbsIconDrawable(context, icon).toString())
|
||||||
|
}.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LibraryStats(
|
||||||
|
var totalItems: Int,
|
||||||
|
var totalSize: Long,
|
||||||
|
var totalDuration: Double,
|
||||||
|
var numAudioFiles: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class SeriesType(
|
||||||
|
var id: String,
|
||||||
|
var name: String,
|
||||||
|
var sequence: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class Folder(
|
||||||
|
var id:String,
|
||||||
|
var fullPath:String
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class BookChapter(
|
||||||
|
var id:Int,
|
||||||
|
var start:Double,
|
||||||
|
var end:Double,
|
||||||
|
var title:String?
|
||||||
|
) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val startMs get() = (start * 1000L).toLong()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val endMs get() = (end * 1000L).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonTypeInfo(use= JsonTypeInfo.Id.DEDUCTION, defaultImpl = MediaProgress::class)
|
||||||
|
@JsonSubTypes(
|
||||||
|
JsonSubTypes.Type(MediaProgress::class),
|
||||||
|
JsonSubTypes.Type(LocalMediaProgress::class)
|
||||||
|
)
|
||||||
|
open class MediaProgressWrapper(var isFinished:Boolean, var currentTime:Double, var progress:Double) {
|
||||||
|
open val mediaItemId get() = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper class
|
||||||
|
data class LibraryItemWithEpisode(
|
||||||
|
var libraryItemWrapper:LibraryItemWrapper,
|
||||||
|
var episode:PodcastEpisode
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LibraryItemSearchResultSeriesItemType(
|
||||||
|
var series: LibrarySeriesItem,
|
||||||
|
var books: List<LibraryItem>?
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LibraryItemSearchResultLibraryItemType(
|
||||||
|
val libraryItem: LibraryItem
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LibraryItemSearchResultType(
|
||||||
|
var book:List<LibraryItemSearchResultLibraryItemType>?,
|
||||||
|
var podcast:List<LibraryItemSearchResultLibraryItemType>?,
|
||||||
|
var series:List<LibraryItemSearchResultSeriesItemType>?,
|
||||||
|
var authors:List<LibraryAuthorItem>?
|
||||||
|
)
|
||||||
|
|
||||||
|
// For personalized shelves
|
||||||
|
@JsonTypeInfo(
|
||||||
|
use=JsonTypeInfo.Id.NAME,
|
||||||
|
property = "type",
|
||||||
|
include = JsonTypeInfo.As.PROPERTY,
|
||||||
|
visible = true
|
||||||
|
)
|
||||||
|
@JsonSubTypes(
|
||||||
|
JsonSubTypes.Type(LibraryShelfBookEntity::class, name = "book"),
|
||||||
|
JsonSubTypes.Type(LibraryShelfSeriesEntity::class, name = "series"),
|
||||||
|
JsonSubTypes.Type(LibraryShelfAuthorEntity::class, name = "authors"),
|
||||||
|
JsonSubTypes.Type(LibraryShelfEpisodeEntity::class, name = "episode"),
|
||||||
|
JsonSubTypes.Type(LibraryShelfPodcastEntity::class, name = "podcast")
|
||||||
|
)
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
sealed class LibraryShelfType(
|
||||||
|
open val id: String,
|
||||||
|
open val label: String,
|
||||||
|
open val total: Int,
|
||||||
|
open val type: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class LibraryShelfBookEntity(
|
||||||
|
override val id: String,
|
||||||
|
override val label: String,
|
||||||
|
override val total: Int,
|
||||||
|
override val type: String,
|
||||||
|
val entities: List<LibraryItem>?
|
||||||
|
) : LibraryShelfType(id, label, total, type)
|
||||||
|
|
||||||
|
data class LibraryShelfSeriesEntity(
|
||||||
|
override val id: String,
|
||||||
|
override val label: String,
|
||||||
|
override val total: Int,
|
||||||
|
override val type: String,
|
||||||
|
val entities: List<LibrarySeriesItem>?
|
||||||
|
) : LibraryShelfType(id, label, total, type)
|
||||||
|
|
||||||
|
data class LibraryShelfAuthorEntity(
|
||||||
|
override val id: String,
|
||||||
|
override val label: String,
|
||||||
|
override val total: Int,
|
||||||
|
override val type: String,
|
||||||
|
val entities: List<LibraryAuthorItem>?
|
||||||
|
) : LibraryShelfType(id, label, total, type)
|
||||||
|
|
||||||
|
data class LibraryShelfEpisodeEntity(
|
||||||
|
override val id: String,
|
||||||
|
override val label: String,
|
||||||
|
override val total: Int,
|
||||||
|
override val type: String,
|
||||||
|
val entities: List<LibraryItem>?
|
||||||
|
) : LibraryShelfType(id, label, total, type)
|
||||||
|
|
||||||
|
data class LibraryShelfPodcastEntity(
|
||||||
|
override val id: String,
|
||||||
|
override val label: String,
|
||||||
|
override val total: Int,
|
||||||
|
override val type: String,
|
||||||
|
val entities: List<LibraryItem>?
|
||||||
|
) : LibraryShelfType(id, label, total, type)
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import android.util.Log
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.fasterxml.jackson.annotation.JsonSubTypes
|
||||||
|
import com.fasterxml.jackson.annotation.JsonTypeInfo
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
enum class LockOrientationSetting {
|
||||||
|
NONE,
|
||||||
|
PORTRAIT,
|
||||||
|
LANDSCAPE
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class HapticFeedbackSetting {
|
||||||
|
OFF,
|
||||||
|
LIGHT,
|
||||||
|
MEDIUM,
|
||||||
|
HEAVY
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class ShakeSensitivitySetting {
|
||||||
|
VERY_LOW,
|
||||||
|
LOW,
|
||||||
|
MEDIUM,
|
||||||
|
HIGH,
|
||||||
|
VERY_HIGH
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class DownloadUsingCellularSetting {
|
||||||
|
ASK,
|
||||||
|
ALWAYS,
|
||||||
|
NEVER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class StreamingUsingCellularSetting {
|
||||||
|
ASK,
|
||||||
|
ALWAYS,
|
||||||
|
NEVER
|
||||||
|
}
|
||||||
|
|
||||||
|
enum class AndroidAutoBrowseSeriesSequenceOrderSetting {
|
||||||
|
ASC,
|
||||||
|
DESC
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ServerConnectionConfig(
|
||||||
|
var id: String,
|
||||||
|
var index: Int,
|
||||||
|
var name: String,
|
||||||
|
var address: String,
|
||||||
|
// version added after 0.9.81-beta
|
||||||
|
var version: String?,
|
||||||
|
var userId: String,
|
||||||
|
var username: String,
|
||||||
|
var token: String,
|
||||||
|
var customHeaders: Map<String, String>?
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LocalFile(
|
||||||
|
var id: String,
|
||||||
|
var filename: String?,
|
||||||
|
var contentUrl: String,
|
||||||
|
var basePath: String,
|
||||||
|
var absolutePath: String,
|
||||||
|
var mimeType: String?,
|
||||||
|
var size: Long
|
||||||
|
) {
|
||||||
|
@JsonIgnore
|
||||||
|
fun exists(ctx: Context): Boolean {
|
||||||
|
if (contentUrl.startsWith("content:")) {
|
||||||
|
return try {
|
||||||
|
ctx.contentResolver.openFileDescriptor(Uri.parse(contentUrl), "r")?.use { true } ?: false
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("LocalFile", "Cannot access SAF file $contentUrl", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return File(absolutePath).exists()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun isAudioFile(): Boolean {
|
||||||
|
if (mimeType == "application/octet-stream") return true
|
||||||
|
if (mimeType == "video/mp4") return true
|
||||||
|
return mimeType?.startsWith("audio") == true
|
||||||
|
}
|
||||||
|
@JsonIgnore
|
||||||
|
fun isEBookFile(): Boolean {
|
||||||
|
return getEBookFormat() != null
|
||||||
|
}
|
||||||
|
@JsonIgnore
|
||||||
|
fun getEBookFormat(): String? {
|
||||||
|
if (mimeType == "application/epub+zip") return "epub"
|
||||||
|
if (mimeType == "application/pdf") return "pdf"
|
||||||
|
if (mimeType == "application/x-mobipocket-ebook") return "mobi"
|
||||||
|
if (mimeType == "application/vnd.comicbook+zip") return "cbz"
|
||||||
|
if (mimeType == "application/vnd.comicbook-rar") return "cbr"
|
||||||
|
if (mimeType == "application/vnd.amazon.mobi8-ebook") return "azw3"
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LocalFolder(
|
||||||
|
var id: String,
|
||||||
|
var name: String,
|
||||||
|
var contentUrl: String,
|
||||||
|
var basePath: String,
|
||||||
|
var absolutePath: String,
|
||||||
|
var storageType: String,
|
||||||
|
var mediaType: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
|
||||||
|
@JsonSubTypes(JsonSubTypes.Type(LibraryItem::class), JsonSubTypes.Type(LocalLibraryItem::class))
|
||||||
|
open class LibraryItemWrapper(var id: String) {
|
||||||
|
@JsonIgnore
|
||||||
|
open fun getMediaDescription(
|
||||||
|
progress: MediaProgressWrapper?,
|
||||||
|
ctx: Context
|
||||||
|
): MediaDescriptionCompat {
|
||||||
|
return MediaDescriptionCompat.Builder().build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class DeviceInfo(
|
||||||
|
var deviceId: String,
|
||||||
|
var manufacturer: String,
|
||||||
|
var model: String,
|
||||||
|
var sdkVersion: Int,
|
||||||
|
var clientVersion: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class PlayItemRequestPayload(
|
||||||
|
var mediaPlayer: String,
|
||||||
|
var forceDirectPlay: Boolean,
|
||||||
|
var forceTranscode: Boolean,
|
||||||
|
var deviceInfo: DeviceInfo
|
||||||
|
)
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class DeviceSettings(
|
||||||
|
var disableAutoRewind: Boolean,
|
||||||
|
var enableAltView: Boolean,
|
||||||
|
var allowSeekingOnMediaControls: Boolean,
|
||||||
|
var jumpBackwardsTime: Int,
|
||||||
|
var jumpForwardTime: Int,
|
||||||
|
var enableMp3IndexSeeking: Boolean,
|
||||||
|
var disableShakeToResetSleepTimer: Boolean,
|
||||||
|
var shakeSensitivity: ShakeSensitivitySetting,
|
||||||
|
var lockOrientation: LockOrientationSetting,
|
||||||
|
var hapticFeedback: HapticFeedbackSetting,
|
||||||
|
var autoSleepTimer: Boolean,
|
||||||
|
var autoSleepTimerStartTime: String,
|
||||||
|
var autoSleepTimerEndTime: String,
|
||||||
|
var autoSleepTimerAutoRewind: Boolean,
|
||||||
|
var autoSleepTimerAutoRewindTime: Long, // Time in milliseconds
|
||||||
|
var sleepTimerLength: Long, // Time in milliseconds
|
||||||
|
var disableSleepTimerFadeOut: Boolean,
|
||||||
|
var disableSleepTimerResetFeedback: Boolean,
|
||||||
|
var enableSleepTimerAlmostDoneChime: Boolean,
|
||||||
|
var languageCode: String,
|
||||||
|
var downloadUsingCellular: DownloadUsingCellularSetting,
|
||||||
|
var streamingUsingCellular: StreamingUsingCellularSetting,
|
||||||
|
var androidAutoBrowseLimitForGrouping: Int,
|
||||||
|
var androidAutoBrowseSeriesSequenceOrder: AndroidAutoBrowseSeriesSequenceOrderSetting
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
// Static method to get default device settings
|
||||||
|
fun default(): DeviceSettings {
|
||||||
|
return DeviceSettings(
|
||||||
|
disableAutoRewind = false,
|
||||||
|
enableAltView = true,
|
||||||
|
allowSeekingOnMediaControls = false,
|
||||||
|
jumpBackwardsTime = 10,
|
||||||
|
jumpForwardTime = 10,
|
||||||
|
enableMp3IndexSeeking = false,
|
||||||
|
disableShakeToResetSleepTimer = false,
|
||||||
|
shakeSensitivity = ShakeSensitivitySetting.MEDIUM,
|
||||||
|
lockOrientation = LockOrientationSetting.NONE,
|
||||||
|
hapticFeedback = HapticFeedbackSetting.LIGHT,
|
||||||
|
autoSleepTimer = false,
|
||||||
|
autoSleepTimerStartTime = "22:00",
|
||||||
|
autoSleepTimerEndTime = "06:00",
|
||||||
|
sleepTimerLength = 900000L, // 15 minutes
|
||||||
|
autoSleepTimerAutoRewind = false,
|
||||||
|
autoSleepTimerAutoRewindTime = 300000L, // 5 minutes
|
||||||
|
disableSleepTimerFadeOut = false,
|
||||||
|
disableSleepTimerResetFeedback = false,
|
||||||
|
enableSleepTimerAlmostDoneChime = false,
|
||||||
|
languageCode = "en-us",
|
||||||
|
downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS,
|
||||||
|
streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS,
|
||||||
|
androidAutoBrowseLimitForGrouping = 100,
|
||||||
|
androidAutoBrowseSeriesSequenceOrder = AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val jumpBackwardsTimeMs
|
||||||
|
get() = jumpBackwardsTime * 1000L
|
||||||
|
@get:JsonIgnore
|
||||||
|
val jumpForwardTimeMs
|
||||||
|
get() = jumpForwardTime * 1000L
|
||||||
|
@get:JsonIgnore
|
||||||
|
val autoSleepTimerStartHour
|
||||||
|
get() = autoSleepTimerStartTime.split(":")[0].toInt()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val autoSleepTimerStartMinute
|
||||||
|
get() = autoSleepTimerStartTime.split(":")[1].toInt()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val autoSleepTimerEndHour
|
||||||
|
get() = autoSleepTimerEndTime.split(":")[0].toInt()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val autoSleepTimerEndMinute
|
||||||
|
get() = autoSleepTimerEndTime.split(":")[1].toInt()
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getShakeThresholdGravity(): Float { // Used in ShakeDetector
|
||||||
|
return if (shakeSensitivity == ShakeSensitivitySetting.VERY_HIGH) 1.1f
|
||||||
|
else if (shakeSensitivity == ShakeSensitivitySetting.HIGH) 1.3f
|
||||||
|
else if (shakeSensitivity == ShakeSensitivitySetting.MEDIUM) 1.5f
|
||||||
|
else if (shakeSensitivity == ShakeSensitivitySetting.LOW) 2f
|
||||||
|
else if (shakeSensitivity == ShakeSensitivitySetting.VERY_LOW) 2.7f
|
||||||
|
else {
|
||||||
|
Log.e("DeviceSetting", "Invalid ShakeSensitivitySetting $shakeSensitivity")
|
||||||
|
1.6f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class DeviceData(
|
||||||
|
var serverConnectionConfigs: MutableList<ServerConnectionConfig>,
|
||||||
|
var lastServerConnectionConfigId: String?,
|
||||||
|
var deviceSettings: DeviceSettings?,
|
||||||
|
var lastPlaybackSession: PlaybackSession?
|
||||||
|
) {
|
||||||
|
@JsonIgnore
|
||||||
|
fun getLastServerConnectionConfig(): ServerConnectionConfig? {
|
||||||
|
return lastServerConnectionConfigId?.let { lsccid ->
|
||||||
|
return serverConnectionConfigs.find { it.id == lsccid }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class EBookFile(
|
||||||
|
var ino:String,
|
||||||
|
var metadata:FileMetadata?,
|
||||||
|
var ebookFormat:String,
|
||||||
|
var isLocal:Boolean,
|
||||||
|
var localFileId:String?,
|
||||||
|
var contentUrl:String?
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
/*
|
||||||
|
Used in Android Auto to represent a podcast episode or an audiobook in progress
|
||||||
|
*/
|
||||||
|
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import com.fasterxml.jackson.module.kotlin.readValue
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class ItemInProgress(
|
||||||
|
val libraryItemWrapper: LibraryItemWrapper,
|
||||||
|
val episode: PodcastEpisode?,
|
||||||
|
val progressLastUpdate: Long,
|
||||||
|
val isLocal: Boolean
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun makeFromServerObject(serverItem: JSONObject, jacksonMapper: ObjectMapper):ItemInProgress {
|
||||||
|
val libraryItem = jacksonMapper.readValue<LibraryItem>(serverItem.toString())
|
||||||
|
|
||||||
|
var episode:PodcastEpisode? = null
|
||||||
|
if (serverItem.has("recentEpisode")) {
|
||||||
|
episode = jacksonMapper.readValue<PodcastEpisode>(serverItem.get("recentEpisode").toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate")
|
||||||
|
return ItemInProgress(libraryItem, episode, progressLastUpdate, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LibraryAuthorItem(
|
||||||
|
id:String,
|
||||||
|
var libraryId:String,
|
||||||
|
var name:String,
|
||||||
|
var description:String?,
|
||||||
|
var imagePath:String?,
|
||||||
|
var addedAt:Long,
|
||||||
|
var updatedAt:Long,
|
||||||
|
var numBooks:Int?,
|
||||||
|
var libraryItems:MutableList<LibraryItem>?,
|
||||||
|
var series:MutableList<LibrarySeriesItem>?
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = name
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val bookCount get() = if (numBooks != null) numBooks else libraryItems!!.size
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getPortraitUri(): Uri {
|
||||||
|
if (imagePath == null) {
|
||||||
|
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.md_account_outline)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uri.parse("${DeviceManager.serverAddress}/api/authors/$id/image?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
if (groupTitle !== null) {
|
||||||
|
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaId = "__LIBRARY__${libraryId}__AUTHOR__${id}"
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(getPortraitUri())
|
||||||
|
.setSubtitle("${bookCount} books")
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
|
return getMediaDescription(progress, ctx, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LibraryCategory(
|
||||||
|
var id:String,
|
||||||
|
var label:String,
|
||||||
|
var type:String,
|
||||||
|
var entities:List<LibraryItemWrapper>,
|
||||||
|
var isLocal:Boolean
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LibraryCollection(
|
||||||
|
id:String,
|
||||||
|
var libraryId:String,
|
||||||
|
var name:String,
|
||||||
|
//var userId:String?,
|
||||||
|
var description:String?,
|
||||||
|
var books:MutableList<LibraryItem>?,
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = name
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val bookCount get() = if (books != null) books!!.size else 0
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val audiobookCount get() = books?.filter { book -> (book.media as Book).getAudioTracks().isNotEmpty() }?.size ?: 0
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
|
||||||
|
val mediaId = "__LIBRARY__${libraryId}__COLLECTION__${id}"
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(title)
|
||||||
|
//.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle("${bookCount} books")
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LibraryItem(
|
||||||
|
id:String,
|
||||||
|
var ino:String,
|
||||||
|
var libraryId:String,
|
||||||
|
var folderId:String,
|
||||||
|
var path:String,
|
||||||
|
var relPath:String,
|
||||||
|
var mtimeMs:Long,
|
||||||
|
var ctimeMs:Long,
|
||||||
|
var birthtimeMs:Long,
|
||||||
|
var addedAt:Long,
|
||||||
|
var updatedAt:Long,
|
||||||
|
var lastScan:Long?,
|
||||||
|
var scanVersion:String?,
|
||||||
|
var isMissing:Boolean,
|
||||||
|
var isInvalid:Boolean,
|
||||||
|
var mediaType:String,
|
||||||
|
var media:MediaType,
|
||||||
|
var libraryFiles:MutableList<LibraryFile>?,
|
||||||
|
var userMediaProgress:MediaProgress?, // Only included when requesting library item with progress (for downloads)
|
||||||
|
var collapsedSeries: CollapsedSeries?,
|
||||||
|
var localLibraryItemId:String?, // For Android Auto
|
||||||
|
val recentEpisode: PodcastEpisode? // Podcast episode shelf uses this
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title: String
|
||||||
|
get() {
|
||||||
|
if (collapsedSeries != null) {
|
||||||
|
return collapsedSeries!!.title
|
||||||
|
}
|
||||||
|
return media.metadata.title
|
||||||
|
}
|
||||||
|
@get:JsonIgnore
|
||||||
|
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCoverUri(): Uri {
|
||||||
|
if (media.coverPath == null) {
|
||||||
|
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
// As of v2.17.0 token is not needed with cover image requests
|
||||||
|
if (DeviceManager.isServerVersionGreaterThanOrEqualTo("2.17.0")) {
|
||||||
|
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover")
|
||||||
|
}
|
||||||
|
|
||||||
|
return Uri.parse("${DeviceManager.serverAddress}/api/items/$id/cover?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun checkHasTracks():Boolean {
|
||||||
|
return media.checkHasTracks()
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val seriesSequence: String
|
||||||
|
get() {
|
||||||
|
if (mediaType != "podcast") {
|
||||||
|
return ((media as Book).metadata as BookMetadata).series?.get(0)?.sequence.orEmpty()
|
||||||
|
} else {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val seriesSequenceParts: List<String>
|
||||||
|
get() {
|
||||||
|
if (seriesSequence.isEmpty()) {
|
||||||
|
return listOf("")
|
||||||
|
}
|
||||||
|
return seriesSequence.split(".", limit = 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?, groupTitle: String?): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
|
||||||
|
if (collapsedSeries == null) {
|
||||||
|
if (localLibraryItemId != null) {
|
||||||
|
extras.putLong(
|
||||||
|
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||||
|
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progress != null) {
|
||||||
|
if (progress.isFinished) {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (mediaType != "podcast") {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.metadata.explicit) {
|
||||||
|
extras.putLong(
|
||||||
|
MediaConstants.METADATA_KEY_IS_EXPLICIT,
|
||||||
|
MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (groupTitle !== null) {
|
||||||
|
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaId = if (localLibraryItemId != null) {
|
||||||
|
localLibraryItemId
|
||||||
|
} else if (collapsedSeries != null) {
|
||||||
|
if (authorId != null) {
|
||||||
|
"__LIBRARY__${libraryId}__AUTHOR_SERIES__${authorId}__${collapsedSeries!!.id}"
|
||||||
|
} else {
|
||||||
|
"__LIBRARY__${libraryId}__SERIES__${collapsedSeries!!.id}"
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
id
|
||||||
|
}
|
||||||
|
var subtitle = authorName
|
||||||
|
if (collapsedSeries != null) {
|
||||||
|
subtitle = "${collapsedSeries!!.numBooks} books"
|
||||||
|
}
|
||||||
|
var itemTitle = title
|
||||||
|
if (showSeriesNumber == true && seriesSequence != "") {
|
||||||
|
itemTitle = "$seriesSequence. $itemTitle"
|
||||||
|
}
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(itemTitle)
|
||||||
|
.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle(subtitle)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?, showSeriesNumber: Boolean?): MediaDescriptionCompat {
|
||||||
|
return getMediaDescription(progress, ctx, authorId, showSeriesNumber, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, authorId: String?): MediaDescriptionCompat {
|
||||||
|
return getMediaDescription(progress, ctx, authorId, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
|
/*
|
||||||
|
This is needed so Android auto library hierarchy for author series can be implemented
|
||||||
|
*/
|
||||||
|
return getMediaDescription(progress, ctx, null, null, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LibrarySeriesItem(
|
||||||
|
id:String,
|
||||||
|
var libraryId:String,
|
||||||
|
var name:String,
|
||||||
|
var description:String?,
|
||||||
|
var addedAt:Long,
|
||||||
|
var updatedAt:Long,
|
||||||
|
var books:MutableList<LibraryItem>?,
|
||||||
|
var localLibraryItemId:String? // For Android Auto
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = name
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val audiobookCount: Int
|
||||||
|
get() {
|
||||||
|
if (books == null) return 0
|
||||||
|
val booksWithAudio = books?.filter { b -> b.media.checkHasTracks() }
|
||||||
|
return booksWithAudio?.size ?: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context, groupTitle: String?): MediaDescriptionCompat {
|
||||||
|
val extras = Bundle()
|
||||||
|
|
||||||
|
if (localLibraryItemId != null) {
|
||||||
|
extras.putLong(
|
||||||
|
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||||
|
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (groupTitle !== null) {
|
||||||
|
extras.putString(MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_GROUP_TITLE, groupTitle)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaId = "__LIBRARY__${libraryId}__SERIES__${id}"
|
||||||
|
return MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setTitle(title)
|
||||||
|
//.setIconUri(getCoverUri())
|
||||||
|
.setSubtitle("$audiobookCount books")
|
||||||
|
.setExtras(extras)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx: Context): MediaDescriptionCompat {
|
||||||
|
return getMediaDescription(progress, ctx, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,197 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.support.v4.media.MediaDescriptionCompat
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import androidx.media.utils.MediaConstants
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.audiobookshelf.app.player.PLAYMETHOD_LOCAL
|
||||||
|
import java.io.File
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LocalLibraryItem(
|
||||||
|
id:String,
|
||||||
|
var folderId:String,
|
||||||
|
var basePath:String,
|
||||||
|
var absolutePath:String,
|
||||||
|
var contentUrl:String,
|
||||||
|
var isInvalid:Boolean,
|
||||||
|
var mediaType:String,
|
||||||
|
var media:MediaType,
|
||||||
|
var localFiles:MutableList<LocalFile>,
|
||||||
|
var coverContentUrl:String?,
|
||||||
|
var coverAbsolutePath:String?,
|
||||||
|
var isLocal:Boolean,
|
||||||
|
// If local library item is linked to a server item
|
||||||
|
var serverConnectionConfigId:String?,
|
||||||
|
var serverAddress:String?,
|
||||||
|
var serverUserId:String?,
|
||||||
|
var libraryItemId:String?
|
||||||
|
) : LibraryItemWrapper(id) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val title get() = media.metadata.title
|
||||||
|
@get:JsonIgnore
|
||||||
|
val authorName get() = media.metadata.getAuthorDisplayName()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isPodcast get() = mediaType == "podcast"
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCoverUri(ctx:Context): Uri {
|
||||||
|
if (coverContentUrl?.startsWith("file:") == true) {
|
||||||
|
return FileProvider.getUriForFile(ctx, "${BuildConfig.APPLICATION_ID}.fileprovider", Uri.parse(coverContentUrl).toFile())
|
||||||
|
}
|
||||||
|
return if (coverContentUrl != null) Uri.parse(coverContentUrl) else Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getDuration():Double {
|
||||||
|
var total = 0.0
|
||||||
|
val audioTracks = media.getAudioTracks()
|
||||||
|
audioTracks.forEach{ total += it.duration }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun updateFromScan(audioTracks:MutableList<AudioTrack>, _localFiles:MutableList<LocalFile>) {
|
||||||
|
localFiles = _localFiles
|
||||||
|
media.setAudioTracks(audioTracks)
|
||||||
|
|
||||||
|
if (coverContentUrl != null) {
|
||||||
|
if (localFiles.find { it.contentUrl == coverContentUrl } == null) {
|
||||||
|
// Cover was removed
|
||||||
|
coverContentUrl = null
|
||||||
|
coverAbsolutePath = null
|
||||||
|
media.coverPath = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun hasTracks(ctx: Context, episode:PodcastEpisode?): Boolean {
|
||||||
|
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||||
|
if (episode != null) { // Get podcast episode audio track
|
||||||
|
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||||
|
}
|
||||||
|
if (audioTracks.size == 0) return false
|
||||||
|
audioTracks.forEach {
|
||||||
|
// Check that metadata is not null
|
||||||
|
if (it.metadata === null) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (!trackExists(ctx, it.contentUrl, it.metadata!!.path)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
private fun trackExists(ctx: Context, contentUrl: String?, path: String): Boolean {
|
||||||
|
return LocalFile("", null, contentUrl ?: "", "", path, null, 0).exists(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getPlaybackSession(episode:PodcastEpisode?, deviceInfo:DeviceInfo):PlaybackSession {
|
||||||
|
val localEpisodeId = episode?.id
|
||||||
|
val sessionId = "${UUID.randomUUID()}"
|
||||||
|
|
||||||
|
// Get current progress for local media
|
||||||
|
val mediaProgressId = if (localEpisodeId.isNullOrEmpty()) id else "$id-$localEpisodeId"
|
||||||
|
val mediaProgress = DeviceManager.dbManager.getLocalMediaProgress(mediaProgressId)
|
||||||
|
val currentTime = if (mediaProgress?.isFinished == true) 0.0 else mediaProgress?.currentTime ?: 0.0
|
||||||
|
|
||||||
|
|
||||||
|
val mediaMetadata = media.metadata
|
||||||
|
var chapters = if (mediaType == "book") (media as Book).chapters else mutableListOf()
|
||||||
|
var audioTracks = media.getAudioTracks() as MutableList<AudioTrack>
|
||||||
|
val authorName = mediaMetadata.getAuthorDisplayName()
|
||||||
|
val displayTitle = episode?.title ?: mediaMetadata.title
|
||||||
|
var duration = getDuration()
|
||||||
|
if (episode != null) { // Get podcast episode audio track
|
||||||
|
episode.audioTrack?.let { at -> mutableListOf(at) }?.let { tracks -> audioTracks = tracks }
|
||||||
|
chapters = episode.chapters
|
||||||
|
duration = episode.audioTrack?.duration ?: 0.0
|
||||||
|
Log.d("LocalLibraryItem", "getPlaybackSession: Got podcast episode audio track ${audioTracks.size}")
|
||||||
|
}
|
||||||
|
|
||||||
|
val dateNow = System.currentTimeMillis()
|
||||||
|
return PlaybackSession(sessionId,serverUserId,libraryItemId,episode?.serverEpisodeId, mediaType, mediaMetadata, deviceInfo,chapters ?: mutableListOf(), displayTitle, authorName,null,duration,PLAYMETHOD_LOCAL,dateNow,0L,0L, audioTracks,currentTime,null,this,localEpisodeId,serverConnectionConfigId, serverAddress, "exo-player")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun removeLocalFile(localFileId:String) {
|
||||||
|
localFiles.removeIf { it.id == localFileId }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
override fun getMediaDescription(progress:MediaProgressWrapper?, ctx:Context): MediaDescriptionCompat {
|
||||||
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
|
var bitmap:Bitmap? = null
|
||||||
|
if (coverContentUrl != null) {
|
||||||
|
bitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||||
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
|
} else {
|
||||||
|
val source: ImageDecoder.Source = ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
|
ImageDecoder.decodeBitmap(source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val extras = Bundle()
|
||||||
|
extras.putLong(
|
||||||
|
MediaDescriptionCompat.EXTRA_DOWNLOAD_STATUS,
|
||||||
|
MediaDescriptionCompat.STATUS_DOWNLOADED
|
||||||
|
)
|
||||||
|
if (progress != null) {
|
||||||
|
if (progress.isFinished) {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_FULLY_PLAYED
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_PARTIALLY_PLAYED
|
||||||
|
)
|
||||||
|
extras.putDouble(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_PERCENTAGE, progress.progress
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else if (mediaType != "podcast") {
|
||||||
|
extras.putInt(
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS,
|
||||||
|
MediaConstants.DESCRIPTION_EXTRAS_VALUE_COMPLETION_STATUS_NOT_PLAYED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.metadata.explicit) {
|
||||||
|
extras.putLong(MediaConstants.METADATA_KEY_IS_EXPLICIT, MediaConstants.METADATA_VALUE_ATTRIBUTE_PRESENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaDescriptionBuilder = MediaDescriptionCompat.Builder()
|
||||||
|
.setMediaId(id)
|
||||||
|
.setTitle(title)
|
||||||
|
.setIconUri(coverUri)
|
||||||
|
.setSubtitle(authorName)
|
||||||
|
.setExtras(extras)
|
||||||
|
|
||||||
|
bitmap?.let {
|
||||||
|
mediaDescriptionBuilder.setIconBitmap(bitmap)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaDescriptionBuilder.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
/*
|
||||||
|
Used as a helper class to generate LocalLibraryItem from scan results
|
||||||
|
*/
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class LocalMediaItem(
|
||||||
|
var id: String,
|
||||||
|
var name: String,
|
||||||
|
var mediaType: String,
|
||||||
|
var folderId: String,
|
||||||
|
var contentUrl: String,
|
||||||
|
var basePath: String,
|
||||||
|
var absolutePath: String,
|
||||||
|
var audioTracks: MutableList<AudioTrack>,
|
||||||
|
var ebookFile: EBookFile?,
|
||||||
|
var localFiles: MutableList<LocalFile>,
|
||||||
|
var coverContentUrl: String?,
|
||||||
|
var coverAbsolutePath: String?
|
||||||
|
) {
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getDuration(): Double {
|
||||||
|
var total = 0.0
|
||||||
|
audioTracks.forEach { total += it.duration }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getTotalSize(): Long {
|
||||||
|
var total = 0L
|
||||||
|
localFiles.forEach { total += it.size }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaMetadata(): MediaTypeMetadata {
|
||||||
|
return if (mediaType == "book") {
|
||||||
|
BookMetadata(
|
||||||
|
name,
|
||||||
|
null,
|
||||||
|
mutableListOf(),
|
||||||
|
mutableListOf(),
|
||||||
|
mutableListOf(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PodcastMetadata(name, null, null, mutableListOf(), false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,86 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class LocalMediaProgress(
|
||||||
|
var id:String,
|
||||||
|
var localLibraryItemId:String,
|
||||||
|
var localEpisodeId:String?,
|
||||||
|
var duration:Double,
|
||||||
|
progress:Double, // 0 to 1
|
||||||
|
currentTime:Double,
|
||||||
|
isFinished:Boolean,
|
||||||
|
var ebookLocation:String?, // cfi tag
|
||||||
|
var ebookProgress:Double?, // 0 to 1
|
||||||
|
var lastUpdate:Long,
|
||||||
|
var startedAt:Long,
|
||||||
|
var finishedAt:Long?,
|
||||||
|
// For local lib items from server to support server sync
|
||||||
|
var serverConnectionConfigId:String?,
|
||||||
|
var serverAddress:String?,
|
||||||
|
var serverUserId:String?,
|
||||||
|
var libraryItemId:String?,
|
||||||
|
var episodeId:String?
|
||||||
|
) : MediaProgressWrapper(isFinished, currentTime, progress) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val progressPercent get() = if (progress.isNaN()) 0 else (progress * 100).roundToInt()
|
||||||
|
@get:JsonIgnore
|
||||||
|
override val mediaItemId get() = if (libraryItemId != null) {
|
||||||
|
if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||||
|
} else {
|
||||||
|
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId"
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun isMatch(mediaProgress:MediaProgress):Boolean {
|
||||||
|
if (episodeId != null) return libraryItemId == mediaProgress.libraryItemId && episodeId == mediaProgress.episodeId
|
||||||
|
return libraryItemId == mediaProgress.libraryItemId
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun updateIsFinished(finished:Boolean) {
|
||||||
|
if (isFinished != finished) { // If finished changed then set progress
|
||||||
|
progress = if (finished) 1.0 else 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finished) {
|
||||||
|
currentTime = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
isFinished = finished
|
||||||
|
lastUpdate = System.currentTimeMillis()
|
||||||
|
finishedAt = if (isFinished) lastUpdate else null
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun updateFromPlaybackSession(playbackSession:PlaybackSession) {
|
||||||
|
currentTime = playbackSession.currentTime
|
||||||
|
progress = playbackSession.progress
|
||||||
|
lastUpdate = playbackSession.updatedAt
|
||||||
|
isFinished = playbackSession.progress >= 0.99
|
||||||
|
finishedAt = if (isFinished) lastUpdate else null
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun updateEbookProgress(ebookLocation:String, ebookProgress:Double) {
|
||||||
|
lastUpdate = System.currentTimeMillis()
|
||||||
|
this.ebookProgress = ebookProgress
|
||||||
|
this.ebookLocation = ebookLocation
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun updateFromServerMediaProgress(serverMediaProgress:MediaProgress) {
|
||||||
|
isFinished = serverMediaProgress.isFinished
|
||||||
|
progress = serverMediaProgress.progress
|
||||||
|
currentTime = serverMediaProgress.currentTime
|
||||||
|
ebookProgress = serverMediaProgress.ebookProgress
|
||||||
|
ebookLocation = serverMediaProgress.ebookLocation
|
||||||
|
duration = serverMediaProgress.duration
|
||||||
|
lastUpdate = serverMediaProgress.lastUpdate
|
||||||
|
finishedAt = serverMediaProgress.finishedAt
|
||||||
|
startedAt = serverMediaProgress.startedAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class MediaItemEvent(
|
||||||
|
var name:String, // e.g. Play/Pause/Stop/Seek/Save
|
||||||
|
var type:String, // Playback/Info
|
||||||
|
var description:String?,
|
||||||
|
var currentTime:Number?, // Seconds
|
||||||
|
var serverSyncAttempted:Boolean?,
|
||||||
|
var serverSyncSuccess:Boolean?,
|
||||||
|
var serverSyncMessage:String?,
|
||||||
|
var timestamp: Long
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class MediaItemHistory(
|
||||||
|
var id: String, // media id
|
||||||
|
var mediaDisplayTitle: String,
|
||||||
|
var libraryItemId: String,
|
||||||
|
var episodeId: String?,
|
||||||
|
var isLocal: Boolean,
|
||||||
|
var serverConnectionConfigId: String?,
|
||||||
|
var serverAddress: String?,
|
||||||
|
var serverUserId: String?,
|
||||||
|
var createdAt: Long,
|
||||||
|
var events: MutableList<MediaItemEvent>,
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class MediaProgress(
|
||||||
|
var id:String,
|
||||||
|
var libraryItemId:String,
|
||||||
|
var episodeId:String?,
|
||||||
|
var duration:Double, // seconds
|
||||||
|
progress:Double, // 0 to 1
|
||||||
|
currentTime:Double,
|
||||||
|
isFinished:Boolean,
|
||||||
|
var ebookLocation:String?, // cfi tag
|
||||||
|
var ebookProgress:Double?, // 0 to 1
|
||||||
|
var lastUpdate:Long,
|
||||||
|
var startedAt:Long,
|
||||||
|
var finishedAt:Long?
|
||||||
|
) : MediaProgressWrapper(isFinished, currentTime, progress) {
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
override val mediaItemId get() = if (episodeId.isNullOrEmpty()) libraryItemId else "$libraryItemId-$episodeId"
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
enum class PlayerState {
|
||||||
|
IDLE, BUFFERING, READY, ENDED
|
||||||
|
}
|
||||||
|
|
||||||
|
data class PlaybackMetadata(
|
||||||
|
val duration:Double,
|
||||||
|
val currentTime:Double,
|
||||||
|
val playerState:PlayerState
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,449 @@
|
||||||
|
package com.audiobookshelf.app.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
|
import androidx.core.content.FileProvider
|
||||||
|
import androidx.core.net.toFile
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.media.MediaProgressSyncData
|
||||||
|
import com.audiobookshelf.app.player.*
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
import com.google.android.exoplayer2.MediaItem
|
||||||
|
import com.google.android.exoplayer2.MediaMetadata
|
||||||
|
import com.google.android.gms.cast.MediaInfo
|
||||||
|
import com.google.android.gms.cast.MediaQueueItem
|
||||||
|
import com.google.android.gms.common.images.WebImage
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
class PlaybackSession(
|
||||||
|
var id: String,
|
||||||
|
var userId: String?,
|
||||||
|
var libraryItemId: String?,
|
||||||
|
var episodeId: String?,
|
||||||
|
var mediaType: String,
|
||||||
|
var mediaMetadata: MediaTypeMetadata,
|
||||||
|
var deviceInfo: DeviceInfo,
|
||||||
|
var chapters: List<BookChapter>,
|
||||||
|
var displayTitle: String?,
|
||||||
|
var displayAuthor: String?,
|
||||||
|
var coverPath: String?,
|
||||||
|
var duration: Double,
|
||||||
|
var playMethod: Int,
|
||||||
|
var startedAt: Long,
|
||||||
|
var updatedAt: Long,
|
||||||
|
var timeListening: Long,
|
||||||
|
var audioTracks: MutableList<AudioTrack>,
|
||||||
|
var currentTime: Double,
|
||||||
|
var libraryItem: LibraryItem?,
|
||||||
|
var localLibraryItem: LocalLibraryItem?,
|
||||||
|
var localEpisodeId: String?,
|
||||||
|
var serverConnectionConfigId: String?,
|
||||||
|
var serverAddress: String?,
|
||||||
|
var mediaPlayer: String?
|
||||||
|
) {
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isHLS
|
||||||
|
get() = playMethod == PLAYMETHOD_TRANSCODE
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isDirectPlay
|
||||||
|
get() = playMethod == PLAYMETHOD_DIRECTPLAY
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isLocal
|
||||||
|
get() = playMethod == PLAYMETHOD_LOCAL
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isPodcastEpisode
|
||||||
|
get() = mediaType == "podcast"
|
||||||
|
@get:JsonIgnore
|
||||||
|
val currentTimeMs
|
||||||
|
get() = (currentTime * 1000L).toLong()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val totalDurationMs
|
||||||
|
get() = (getTotalDuration() * 1000L).toLong()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val localLibraryItemId
|
||||||
|
get() = localLibraryItem?.id ?: ""
|
||||||
|
@get:JsonIgnore
|
||||||
|
val localMediaProgressId
|
||||||
|
get() =
|
||||||
|
if (localEpisodeId.isNullOrEmpty()) localLibraryItemId
|
||||||
|
else "$localLibraryItemId-$localEpisodeId"
|
||||||
|
@get:JsonIgnore
|
||||||
|
val progress
|
||||||
|
get() = currentTime / getTotalDuration()
|
||||||
|
@get:JsonIgnore
|
||||||
|
val mediaItemId
|
||||||
|
get() = if (episodeId.isNullOrEmpty()) libraryItemId ?: "" else "$libraryItemId-$episodeId"
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCurrentTrackIndex(): Int {
|
||||||
|
for (i in 0 until audioTracks.size) {
|
||||||
|
val track = audioTracks[i]
|
||||||
|
if (currentTimeMs >= track.startOffsetMs && (track.endOffsetMs > currentTimeMs)) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return audioTracks.size - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getNextTrackIndex(): Int {
|
||||||
|
for (i in 0 until audioTracks.size) {
|
||||||
|
val track = audioTracks[i]
|
||||||
|
if (currentTimeMs < track.startOffsetMs) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return audioTracks.size - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getChapterForTime(time: Long): BookChapter? {
|
||||||
|
if (chapters.isEmpty()) return null
|
||||||
|
return chapters.find { time >= it.startMs && it.endMs > time }
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCurrentTrackEndTime(): Long {
|
||||||
|
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||||
|
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getNextChapterForTime(time: Long): BookChapter? {
|
||||||
|
if (chapters.isEmpty()) return null
|
||||||
|
return chapters.find { time < it.startMs } // First chapter where start time is > then time
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getNextTrackEndTime(): Long {
|
||||||
|
val currentTrack = audioTracks[this.getNextTrackIndex()]
|
||||||
|
return currentTrack.startOffsetMs + currentTrack.durationMs
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCurrentTrackTimeMs(): Long {
|
||||||
|
val currentTrack = audioTracks[this.getCurrentTrackIndex()]
|
||||||
|
val time = currentTime - currentTrack.startOffset
|
||||||
|
return (time * 1000L).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getTrackStartOffsetMs(index: Int): Long {
|
||||||
|
if (index < 0 || index >= audioTracks.size) return 0L
|
||||||
|
val currentTrack = audioTracks[index]
|
||||||
|
return (currentTrack.startOffset * 1000L).toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getTotalDuration(): Double {
|
||||||
|
var total = 0.0
|
||||||
|
audioTracks.forEach { total += it.duration }
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun checkIsServerVersionGte(compareVersion: String): Boolean {
|
||||||
|
// Safety check this playback session is the same one currently connected (should always be)
|
||||||
|
if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return DeviceManager.isServerVersionGreaterThanOrEqualTo(compareVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCoverUri(ctx: Context): Uri {
|
||||||
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
|
var coverUri = Uri.parse(localLibraryItem?.coverContentUrl.toString())
|
||||||
|
if (coverUri.toString().startsWith("file:")) {
|
||||||
|
coverUri =
|
||||||
|
FileProvider.getUriForFile(
|
||||||
|
ctx,
|
||||||
|
"${BuildConfig.APPLICATION_ID}.fileprovider",
|
||||||
|
coverUri.toFile()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return coverUri
|
||||||
|
?: Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coverPath == null)
|
||||||
|
return Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon)
|
||||||
|
|
||||||
|
// As of v2.17.0 token is not needed with cover image requests
|
||||||
|
if (checkIsServerVersionGte("2.17.0")) {
|
||||||
|
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||||
|
}
|
||||||
|
return Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getContentUri(audioTrack: AudioTrack): Uri {
|
||||||
|
if (isLocal) return Uri.parse(audioTrack.contentUrl) // Local content url
|
||||||
|
// As of v2.22.0 tracks use a different endpoint
|
||||||
|
// See: https://github.com/advplyr/audiobookshelf/pull/4263
|
||||||
|
if (checkIsServerVersionGte("2.22.0")) {
|
||||||
|
return if (isDirectPlay) {
|
||||||
|
Uri.parse("$serverAddress/public/session/$id/track/${audioTrack.index}")
|
||||||
|
} else {
|
||||||
|
// Transcode uses HlsRouter on server
|
||||||
|
Uri.parse("$serverAddress${audioTrack.contentUrl}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Uri.parse("$serverAddress${audioTrack.contentUrl}?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bitmap for the cover art, once resolved */
|
||||||
|
@JsonIgnore
|
||||||
|
private var resolvedCoverBitmap: Bitmap? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the current session metadata, including the cover art bitmap if it has already been resolved.
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaMetadataCompat(ctx: Context): MediaMetadataCompat {
|
||||||
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
|
val metadataBuilder =
|
||||||
|
MediaMetadataCompat.Builder()
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, displayTitle)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_TITLE, displayTitle)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_SUBTITLE, displayAuthor)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_AUTHOR, displayAuthor)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, displayAuthor)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, displayAuthor)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ARTIST, displayAuthor)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_DISPLAY_DESCRIPTION, displayAuthor)
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, id)
|
||||||
|
.putLong(MediaMetadataCompat.METADATA_KEY_DURATION, totalDurationMs)
|
||||||
|
.putString(
|
||||||
|
MediaMetadataCompat.METADATA_KEY_DISPLAY_ICON_URI,
|
||||||
|
coverUri.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (resolvedCoverBitmap != null) {
|
||||||
|
metadataBuilder
|
||||||
|
.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, resolvedCoverBitmap)
|
||||||
|
.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, resolvedCoverBitmap)
|
||||||
|
} else {
|
||||||
|
metadataBuilder
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, coverUri.toString())
|
||||||
|
.putString(MediaMetadataCompat.METADATA_KEY_ART_URI, coverUri.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadataBuilder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the cover art bitmap (local covers are decoded synchronously, server-side covers
|
||||||
|
* are fetched asynchronously) and calls `onArtResolved` once it's available
|
||||||
|
*
|
||||||
|
* Returns the Job for the async fetch, or `null` if the bitmap was resolved synchronously.
|
||||||
|
*/
|
||||||
|
@JsonIgnore
|
||||||
|
fun resolveCoverBitmapAsync(
|
||||||
|
ctx: Context,
|
||||||
|
coroutineScope: CoroutineScope,
|
||||||
|
onArtResolved: () -> Unit
|
||||||
|
): Job? {
|
||||||
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
|
// Local covers get bitmap synchronously, no async fetch needed
|
||||||
|
if (localLibraryItem?.coverContentUrl != null) {
|
||||||
|
resolvedCoverBitmap =
|
||||||
|
if (Build.VERSION.SDK_INT < 28) {
|
||||||
|
MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri)
|
||||||
|
} else {
|
||||||
|
val source: ImageDecoder.Source =
|
||||||
|
ImageDecoder.createSource(ctx.contentResolver, coverUri)
|
||||||
|
ImageDecoder.decodeBitmap(source)
|
||||||
|
}
|
||||||
|
onArtResolved()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Server-side cover: resolve the art bitmap async
|
||||||
|
return coroutineScope.launch {
|
||||||
|
val bitmap = resolveUriAsBitmap(ctx, coverUri)
|
||||||
|
bitmap?.let {
|
||||||
|
resolvedCoverBitmap = it
|
||||||
|
onArtResolved()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getExoMediaMetadata(ctx: Context): MediaMetadata {
|
||||||
|
val coverUri = getCoverUri(ctx)
|
||||||
|
|
||||||
|
val metadataBuilder =
|
||||||
|
MediaMetadata.Builder()
|
||||||
|
.setTitle(displayTitle)
|
||||||
|
.setDisplayTitle(displayTitle)
|
||||||
|
.setArtist(displayAuthor)
|
||||||
|
.setAlbumArtist(displayAuthor)
|
||||||
|
.setSubtitle(displayAuthor)
|
||||||
|
.setAlbumTitle(displayAuthor)
|
||||||
|
.setDescription(displayAuthor)
|
||||||
|
.setArtworkUri(coverUri)
|
||||||
|
.setMediaType(MediaMetadata.MEDIA_TYPE_AUDIO_BOOK)
|
||||||
|
|
||||||
|
return metadataBuilder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getMediaItems(ctx: Context): List<MediaItem> {
|
||||||
|
val mediaItems: MutableList<MediaItem> = mutableListOf()
|
||||||
|
|
||||||
|
for (audioTrack in audioTracks) {
|
||||||
|
val mediaMetadata = this.getExoMediaMetadata(ctx)
|
||||||
|
val mediaUri = this.getContentUri(audioTrack)
|
||||||
|
val mimeType = audioTrack.mimeType
|
||||||
|
|
||||||
|
val queueItem = getQueueItem(audioTrack) // Queue item used in exo player CastManager
|
||||||
|
val mediaItem =
|
||||||
|
MediaItem.Builder()
|
||||||
|
.setUri(mediaUri)
|
||||||
|
.setTag(queueItem)
|
||||||
|
.setMediaMetadata(mediaMetadata)
|
||||||
|
.setMimeType(mimeType)
|
||||||
|
.build()
|
||||||
|
mediaItems.add(mediaItem)
|
||||||
|
}
|
||||||
|
return mediaItems
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getCastMediaMetadata(audioTrack: AudioTrack): com.google.android.gms.cast.MediaMetadata {
|
||||||
|
val castMetadata =
|
||||||
|
com.google.android.gms.cast.MediaMetadata(
|
||||||
|
com.google.android.gms.cast.MediaMetadata.MEDIA_TYPE_AUDIOBOOK_CHAPTER
|
||||||
|
)
|
||||||
|
|
||||||
|
// As of v2.17.0 token is not needed with cover image requests
|
||||||
|
val coverUri = if (checkIsServerVersionGte("2.17.0")) {
|
||||||
|
Uri.parse("$serverAddress/api/items/$libraryItemId/cover")
|
||||||
|
} else {
|
||||||
|
Uri.parse("$serverAddress/api/items/$libraryItemId/cover?token=${DeviceManager.token}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast always uses server cover uri
|
||||||
|
coverPath?.let {
|
||||||
|
castMetadata.addImage(WebImage(coverUri))
|
||||||
|
}
|
||||||
|
|
||||||
|
castMetadata.putString(com.google.android.gms.cast.MediaMetadata.KEY_TITLE, displayTitle ?: "")
|
||||||
|
castMetadata.putString(
|
||||||
|
com.google.android.gms.cast.MediaMetadata.KEY_ARTIST,
|
||||||
|
displayAuthor ?: ""
|
||||||
|
)
|
||||||
|
castMetadata.putString(
|
||||||
|
com.google.android.gms.cast.MediaMetadata.KEY_ALBUM_TITLE,
|
||||||
|
displayAuthor ?: ""
|
||||||
|
)
|
||||||
|
castMetadata.putString(
|
||||||
|
com.google.android.gms.cast.MediaMetadata.KEY_CHAPTER_TITLE,
|
||||||
|
audioTrack.title
|
||||||
|
)
|
||||||
|
|
||||||
|
castMetadata.putInt(
|
||||||
|
com.google.android.gms.cast.MediaMetadata.KEY_TRACK_NUMBER,
|
||||||
|
audioTrack.index
|
||||||
|
)
|
||||||
|
return castMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getQueueItem(audioTrack: AudioTrack): MediaQueueItem {
|
||||||
|
val castMetadata = getCastMediaMetadata(audioTrack)
|
||||||
|
|
||||||
|
val mediaUri = getContentUri(audioTrack)
|
||||||
|
|
||||||
|
val mediaInfo =
|
||||||
|
MediaInfo.Builder(mediaUri.toString())
|
||||||
|
.apply {
|
||||||
|
setContentUrl(mediaUri.toString())
|
||||||
|
setContentType(audioTrack.mimeType ?: "")
|
||||||
|
setMetadata(castMetadata)
|
||||||
|
setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
|
||||||
|
return MediaQueueItem.Builder(mediaInfo)
|
||||||
|
.apply { setPlaybackDuration(audioTrack.duration) }
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun clone(): PlaybackSession {
|
||||||
|
return PlaybackSession(
|
||||||
|
id,
|
||||||
|
userId,
|
||||||
|
libraryItemId,
|
||||||
|
episodeId,
|
||||||
|
mediaType,
|
||||||
|
mediaMetadata,
|
||||||
|
deviceInfo,
|
||||||
|
chapters,
|
||||||
|
displayTitle,
|
||||||
|
displayAuthor,
|
||||||
|
coverPath,
|
||||||
|
duration,
|
||||||
|
playMethod,
|
||||||
|
startedAt,
|
||||||
|
updatedAt,
|
||||||
|
timeListening,
|
||||||
|
audioTracks,
|
||||||
|
currentTime,
|
||||||
|
libraryItem,
|
||||||
|
localLibraryItem,
|
||||||
|
localEpisodeId,
|
||||||
|
serverConnectionConfigId,
|
||||||
|
serverAddress,
|
||||||
|
mediaPlayer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun syncData(syncData: MediaProgressSyncData) {
|
||||||
|
timeListening += syncData.timeListened
|
||||||
|
updatedAt = System.currentTimeMillis()
|
||||||
|
currentTime = syncData.currentTime
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getNewLocalMediaProgress(): LocalMediaProgress {
|
||||||
|
return LocalMediaProgress(
|
||||||
|
localMediaProgressId,
|
||||||
|
localLibraryItemId,
|
||||||
|
localEpisodeId,
|
||||||
|
getTotalDuration(),
|
||||||
|
progress,
|
||||||
|
currentTime,
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
updatedAt,
|
||||||
|
startedAt,
|
||||||
|
null,
|
||||||
|
serverConnectionConfigId,
|
||||||
|
serverAddress,
|
||||||
|
userId,
|
||||||
|
libraryItemId,
|
||||||
|
episodeId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,228 @@
|
||||||
|
package com.audiobookshelf.app.device
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.MediaPlayerWidget
|
||||||
|
import com.audiobookshelf.app.data.*
|
||||||
|
import com.audiobookshelf.app.managers.DbManager
|
||||||
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
import com.audiobookshelf.app.updateAppWidget
|
||||||
|
|
||||||
|
/** Interface for widget event handling. */
|
||||||
|
interface WidgetEventEmitter {
|
||||||
|
/**
|
||||||
|
* Called when the player state changes.
|
||||||
|
* @param pns The PlayerNotificationService instance.
|
||||||
|
*/
|
||||||
|
fun onPlayerChanged(pns: PlayerNotificationService)
|
||||||
|
|
||||||
|
/** Called when the player is closed. */
|
||||||
|
fun onPlayerClosed()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Singleton object for managing device-related operations. */
|
||||||
|
object DeviceManager {
|
||||||
|
const val tag = "DeviceManager"
|
||||||
|
|
||||||
|
val dbManager: DbManager = DbManager()
|
||||||
|
var deviceData: DeviceData = dbManager.getDeviceData()
|
||||||
|
var serverConnectionConfig: ServerConnectionConfig? = null
|
||||||
|
|
||||||
|
val serverConnectionConfigId get() = serverConnectionConfig?.id ?: ""
|
||||||
|
val serverConnectionConfigName get() = serverConnectionConfig?.name ?: ""
|
||||||
|
val serverConnectionConfigString get() = serverConnectionConfig?.name ?: "No server connection"
|
||||||
|
val serverAddress
|
||||||
|
get() = serverConnectionConfig?.address ?: ""
|
||||||
|
val serverUserId
|
||||||
|
get() = serverConnectionConfig?.userId ?: ""
|
||||||
|
val token
|
||||||
|
get() = serverConnectionConfig?.token ?: ""
|
||||||
|
val serverVersion get() = serverConnectionConfig?.version ?: ""
|
||||||
|
val isConnectedToServer
|
||||||
|
get() = serverConnectionConfig != null
|
||||||
|
|
||||||
|
var widgetUpdater: WidgetEventEmitter? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
Log.d(tag, "Device Manager Singleton invoked")
|
||||||
|
|
||||||
|
// Initialize new sleep timer settings and shake sensitivity added in v0.9.61
|
||||||
|
if (deviceData.deviceSettings?.autoSleepTimerStartTime == null ||
|
||||||
|
deviceData.deviceSettings?.autoSleepTimerEndTime == null
|
||||||
|
) {
|
||||||
|
deviceData.deviceSettings?.autoSleepTimerStartTime = "22:00"
|
||||||
|
deviceData.deviceSettings?.autoSleepTimerEndTime = "06:00"
|
||||||
|
deviceData.deviceSettings?.sleepTimerLength = 900000L
|
||||||
|
}
|
||||||
|
if (deviceData.deviceSettings?.shakeSensitivity == null) {
|
||||||
|
deviceData.deviceSettings?.shakeSensitivity = ShakeSensitivitySetting.MEDIUM
|
||||||
|
}
|
||||||
|
// Initialize auto sleep timer auto rewind added in v0.9.64
|
||||||
|
if (deviceData.deviceSettings?.autoSleepTimerAutoRewindTime == null) {
|
||||||
|
deviceData.deviceSettings?.autoSleepTimerAutoRewindTime = 300000L // 5 minutes
|
||||||
|
}
|
||||||
|
// Initialize sleep timer almost done chime added in v0.9.81
|
||||||
|
if (deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == null) {
|
||||||
|
deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime = false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Language added in v0.9.69
|
||||||
|
if (deviceData.deviceSettings?.languageCode == null) {
|
||||||
|
deviceData.deviceSettings?.languageCode = "en-us"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceData.deviceSettings?.downloadUsingCellular == null) {
|
||||||
|
deviceData.deviceSettings?.downloadUsingCellular = DownloadUsingCellularSetting.ALWAYS
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceData.deviceSettings?.streamingUsingCellular == null) {
|
||||||
|
deviceData.deviceSettings?.streamingUsingCellular = StreamingUsingCellularSetting.ALWAYS
|
||||||
|
}
|
||||||
|
if (deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping == null) {
|
||||||
|
deviceData.deviceSettings?.androidAutoBrowseLimitForGrouping = 100
|
||||||
|
}
|
||||||
|
if (deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder == null) {
|
||||||
|
deviceData.deviceSettings?.androidAutoBrowseSeriesSequenceOrder =
|
||||||
|
AndroidAutoBrowseSeriesSequenceOrderSetting.ASC
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encodes the given ID to a Base64 string.
|
||||||
|
* @param id The ID to encode.
|
||||||
|
* @return The Base64 encoded string.
|
||||||
|
*/
|
||||||
|
fun getBase64Id(id: String): String {
|
||||||
|
return android.util.Base64.encodeToString(
|
||||||
|
id.toByteArray(),
|
||||||
|
android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves the server connection configuration for the given ID.
|
||||||
|
* @param id The ID of the server connection configuration.
|
||||||
|
* @return The ServerConnectionConfig instance or null if not found.
|
||||||
|
*/
|
||||||
|
fun getServerConnectionConfig(id: String?): ServerConnectionConfig? {
|
||||||
|
return id?.let { deviceData.serverConnectionConfigs.find { it.id == id } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the currently connected server version is >= compareVersion
|
||||||
|
* Abs server only uses major.minor.patch
|
||||||
|
* Note: Version is returned in Abs auth payloads starting v2.6.0
|
||||||
|
* Note: Version is saved with the server connection config starting after v0.9.81
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* serverVersion=2.25.1
|
||||||
|
* isServerVersionGreaterThanOrEqualTo("2.26.0") = false
|
||||||
|
*
|
||||||
|
* serverVersion=2.26.1
|
||||||
|
* isServerVersionGreaterThanOrEqualTo("2.26.0") = true
|
||||||
|
*/
|
||||||
|
fun isServerVersionGreaterThanOrEqualTo(compareVersion:String):Boolean {
|
||||||
|
if (serverVersion == "") return false
|
||||||
|
if (compareVersion == "") return true
|
||||||
|
|
||||||
|
val serverVersionParts = serverVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
val compareVersionParts = compareVersion.split(".").map { it.toIntOrNull() ?: 0 }
|
||||||
|
|
||||||
|
// Compare major, minor, and patch components
|
||||||
|
for (i in 0 until maxOf(serverVersionParts.size, compareVersionParts.size)) {
|
||||||
|
val serverVersionComponent = serverVersionParts.getOrElse(i) { 0 }
|
||||||
|
val compareVersionComponent = compareVersionParts.getOrElse(i) { 0 }
|
||||||
|
|
||||||
|
if (serverVersionComponent < compareVersionComponent) {
|
||||||
|
return false // Server version is less than compareVersion
|
||||||
|
} else if (serverVersionComponent > compareVersionComponent) {
|
||||||
|
return true // Server version is greater than compareVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true // versions are equal in major, minor, and patch
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the network connectivity status.
|
||||||
|
* @param ctx The context to use for checking connectivity.
|
||||||
|
* @return True if connected to the internet, false otherwise.
|
||||||
|
*/
|
||||||
|
fun checkConnectivity(ctx: Context): Boolean {
|
||||||
|
val connectivityManager =
|
||||||
|
ctx.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
val capabilities = connectivityManager.getNetworkCapabilities(connectivityManager.activeNetwork)
|
||||||
|
if (capabilities != null) {
|
||||||
|
if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) {
|
||||||
|
Log.i("Internet", "NetworkCapabilities.TRANSPORT_CELLULAR")
|
||||||
|
return true
|
||||||
|
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) {
|
||||||
|
Log.i("Internet", "NetworkCapabilities.TRANSPORT_WIFI")
|
||||||
|
return true
|
||||||
|
} else if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET)) {
|
||||||
|
Log.i("Internet", "NetworkCapabilities.TRANSPORT_ETHERNET")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the last playback session.
|
||||||
|
* @param playbackSession The playback session to set.
|
||||||
|
*/
|
||||||
|
fun setLastPlaybackSession(playbackSession: PlaybackSession) {
|
||||||
|
deviceData.lastPlaybackSession = playbackSession
|
||||||
|
dbManager.saveDeviceData(deviceData)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the widget updater.
|
||||||
|
* @param context The context to use for initializing the widget updater.
|
||||||
|
*/
|
||||||
|
fun initializeWidgetUpdater(context: Context) {
|
||||||
|
Log.d(tag, "Initializing widget updater")
|
||||||
|
widgetUpdater =
|
||||||
|
(object : WidgetEventEmitter {
|
||||||
|
override fun onPlayerChanged(pns: PlayerNotificationService) {
|
||||||
|
val isPlaying = pns.currentPlayer.isPlaying
|
||||||
|
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||||
|
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||||
|
val playbackSession = pns.getCurrentPlaybackSessionCopy()
|
||||||
|
|
||||||
|
for (widgetId in ids) {
|
||||||
|
updateAppWidget(
|
||||||
|
context,
|
||||||
|
appWidgetManager,
|
||||||
|
widgetId,
|
||||||
|
playbackSession,
|
||||||
|
isPlaying,
|
||||||
|
PlayerNotificationService.isClosed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayerClosed() {
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val componentName = ComponentName(context, MediaPlayerWidget::class.java)
|
||||||
|
val ids = appWidgetManager.getAppWidgetIds(componentName)
|
||||||
|
for (widgetId in ids) {
|
||||||
|
updateAppWidget(
|
||||||
|
context,
|
||||||
|
appWidgetManager,
|
||||||
|
widgetId,
|
||||||
|
deviceData.lastPlaybackSession,
|
||||||
|
false,
|
||||||
|
PlayerNotificationService.isClosed
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,312 @@
|
||||||
|
package com.audiobookshelf.app.device
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.anggrayudi.storage.file.*
|
||||||
|
import com.audiobookshelf.app.data.*
|
||||||
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import com.audiobookshelf.app.models.DownloadItemPart
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/** Creates local-library records from a completed download manifest. */
|
||||||
|
class FolderScanner(private val ctx: Context) {
|
||||||
|
private val tag = "FolderScanner"
|
||||||
|
|
||||||
|
data class DownloadItemScanResult(
|
||||||
|
val localLibraryItem: LocalLibraryItem,
|
||||||
|
var localMediaProgress: LocalMediaProgress?
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun localLibraryItemId(mediaItemId: String) =
|
||||||
|
"local_${DeviceManager.getBase64Id(mediaItemId)}"
|
||||||
|
|
||||||
|
private fun createLocalFile(
|
||||||
|
part: DownloadItemPart,
|
||||||
|
externalFile: DocumentFile? = null
|
||||||
|
): LocalFile? {
|
||||||
|
if (part.isInternalStorage) {
|
||||||
|
val file = File(part.finalDestinationPath)
|
||||||
|
if (!file.exists()) return null
|
||||||
|
return LocalFile(
|
||||||
|
DeviceManager.getBase64Id(file.name),
|
||||||
|
file.name,
|
||||||
|
Uri.fromFile(file).toString(),
|
||||||
|
file.getBasePath(ctx),
|
||||||
|
file.absolutePath,
|
||||||
|
file.mimeType,
|
||||||
|
file.length()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
part.completedDestinationUri?.let { contentUrl ->
|
||||||
|
val uri = Uri.parse(contentUrl)
|
||||||
|
val size =
|
||||||
|
try {
|
||||||
|
ctx.contentResolver.openFileDescriptor(uri, "r")?.use { descriptor ->
|
||||||
|
descriptor.statSize.coerceAtLeast(0L)
|
||||||
|
}
|
||||||
|
?: 0L
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(tag, "Could not open completed SAF file: $contentUrl", e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
// Android 10 DownloadsProvider may not reconstruct a DocumentFile for an audio URI even
|
||||||
|
// though the URI remains readable. Keep the URI as the authoritative local-file location.
|
||||||
|
return LocalFile(
|
||||||
|
DeviceManager.getBase64Id(contentUrl),
|
||||||
|
part.filename,
|
||||||
|
contentUrl,
|
||||||
|
part.localFolderName,
|
||||||
|
part.finalDestinationPath,
|
||||||
|
mimeTypeFor(part),
|
||||||
|
size
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Do not reconstruct a DocumentFile from an absolute path: on Android 10 that becomes a
|
||||||
|
// file:// URI, which DocumentsContract rejects. The caller resolves this from the persisted
|
||||||
|
// SAF tree grant instead.
|
||||||
|
val document = externalFile
|
||||||
|
if (document == null || !document.exists()) {
|
||||||
|
Log.e(tag, "Could not resolve downloaded SAF file: ${part.finalDestinationPath}")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return LocalFile(
|
||||||
|
DeviceManager.getBase64Id(document.id),
|
||||||
|
document.name,
|
||||||
|
document.uri.toString(),
|
||||||
|
document.getBasePath(ctx),
|
||||||
|
document.getAbsolutePath(ctx),
|
||||||
|
document.mimeType,
|
||||||
|
document.length()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun newLocalLibraryItem(
|
||||||
|
id: String,
|
||||||
|
downloadItem: DownloadItem,
|
||||||
|
basePath: String,
|
||||||
|
absolutePath: String,
|
||||||
|
contentUrl: String
|
||||||
|
) =
|
||||||
|
LocalLibraryItem(
|
||||||
|
id,
|
||||||
|
downloadItem.localFolder.id,
|
||||||
|
basePath,
|
||||||
|
absolutePath,
|
||||||
|
contentUrl,
|
||||||
|
false,
|
||||||
|
downloadItem.mediaType,
|
||||||
|
downloadItem.media.getLocalCopy(),
|
||||||
|
mutableListOf(),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
true,
|
||||||
|
downloadItem.serverConnectionConfigId,
|
||||||
|
downloadItem.serverAddress,
|
||||||
|
downloadItem.serverUserId,
|
||||||
|
downloadItem.libraryItemId
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun scanParts(
|
||||||
|
item: DownloadItem,
|
||||||
|
localItem: LocalLibraryItem,
|
||||||
|
externalFolder: DocumentFile? = null,
|
||||||
|
callback: (DownloadItemScanResult?) -> Unit
|
||||||
|
) {
|
||||||
|
val tracks = mutableListOf<AudioTrack>()
|
||||||
|
var foundEbook = false
|
||||||
|
var localEpisodeId: String? = null
|
||||||
|
|
||||||
|
item.downloadItemParts.forEach { part ->
|
||||||
|
val externalFile =
|
||||||
|
if (part.isInternalStorage) {
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
part.completedDestinationUri?.let { DocumentFileCompat.fromUri(ctx, Uri.parse(it)) }
|
||||||
|
?: resolveExternalFile(externalFolder, part)
|
||||||
|
}
|
||||||
|
Log.d(tag, "Resolve part ${part.filename}: externalFile=${externalFile?.uri}")
|
||||||
|
val localFile = createLocalFile(part, externalFile) ?: return@forEach
|
||||||
|
when {
|
||||||
|
part.audioTrack != null -> {
|
||||||
|
val serverTrack = part.audioTrack
|
||||||
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
|
localItem.localFiles.add(localFile)
|
||||||
|
val metadata =
|
||||||
|
FileMetadata(
|
||||||
|
localFile.filename ?: "",
|
||||||
|
File(localFile.filename ?: "").extension,
|
||||||
|
localFile.absolutePath,
|
||||||
|
localFile.basePath,
|
||||||
|
localFile.size
|
||||||
|
)
|
||||||
|
val track =
|
||||||
|
AudioTrack(
|
||||||
|
serverTrack.index,
|
||||||
|
serverTrack.startOffset,
|
||||||
|
serverTrack.duration,
|
||||||
|
localFile.filename ?: "",
|
||||||
|
localFile.contentUrl,
|
||||||
|
localFile.mimeType ?: "",
|
||||||
|
metadata,
|
||||||
|
true,
|
||||||
|
localFile.id,
|
||||||
|
serverTrack.index
|
||||||
|
)
|
||||||
|
tracks.add(track)
|
||||||
|
Log.d(tag, "Added local audio track ${track.contentUrl} (${track.metadata?.path})")
|
||||||
|
part.episode?.let { episode ->
|
||||||
|
val podcast = localItem.media as Podcast
|
||||||
|
localEpisodeId = podcast.addEpisode(track, episode).id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
part.ebookFile != null -> {
|
||||||
|
foundEbook = true
|
||||||
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
|
localItem.localFiles.add(localFile)
|
||||||
|
(localItem.media as Book).ebookFile =
|
||||||
|
EBookFile(
|
||||||
|
part.ebookFile.ino,
|
||||||
|
part.ebookFile.metadata,
|
||||||
|
part.ebookFile.ebookFormat,
|
||||||
|
true,
|
||||||
|
localFile.id,
|
||||||
|
localFile.contentUrl
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
localItem.coverAbsolutePath = localFile.absolutePath
|
||||||
|
localItem.coverContentUrl = localFile.contentUrl
|
||||||
|
localItem.localFiles.removeAll { it.id == localFile.id }
|
||||||
|
localItem.localFiles.add(localFile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tracks.isEmpty() && !foundEbook) {
|
||||||
|
callback(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (item.mediaType == "book") {
|
||||||
|
tracks.sortBy { it.index }
|
||||||
|
var expectedIndex = 1
|
||||||
|
var offset = 0.0
|
||||||
|
tracks.forEach { track ->
|
||||||
|
track.index = expectedIndex++
|
||||||
|
track.startOffset = offset
|
||||||
|
offset += track.duration
|
||||||
|
}
|
||||||
|
localItem.media.setAudioTracks(tracks)
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = DownloadItemScanResult(localItem, null)
|
||||||
|
item.userMediaProgress?.let { progress ->
|
||||||
|
val progressId =
|
||||||
|
if (item.episodeId.isNullOrEmpty()) localItem.id
|
||||||
|
else "${localItem.id}-$localEpisodeId"
|
||||||
|
result.localMediaProgress =
|
||||||
|
LocalMediaProgress(
|
||||||
|
progressId,
|
||||||
|
localItem.id,
|
||||||
|
localEpisodeId,
|
||||||
|
progress.duration,
|
||||||
|
progress.progress,
|
||||||
|
progress.currentTime,
|
||||||
|
progress.isFinished,
|
||||||
|
progress.ebookLocation,
|
||||||
|
progress.ebookProgress,
|
||||||
|
progress.lastUpdate,
|
||||||
|
progress.startedAt,
|
||||||
|
progress.finishedAt,
|
||||||
|
item.serverConnectionConfigId,
|
||||||
|
item.serverAddress,
|
||||||
|
item.serverUserId,
|
||||||
|
item.libraryItemId,
|
||||||
|
item.episodeId
|
||||||
|
)
|
||||||
|
DeviceManager.dbManager.saveLocalMediaProgress(result.localMediaProgress!!)
|
||||||
|
}
|
||||||
|
DeviceManager.dbManager.saveLocalLibraryItem(localItem)
|
||||||
|
callback(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findFolderByPath(root: DocumentFile, subPath: String): DocumentFile? {
|
||||||
|
if (subPath.isBlank()) return root
|
||||||
|
var current = root
|
||||||
|
subPath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||||
|
if (segment == "." || segment == "..") return null
|
||||||
|
current = current.findFile(segment) ?: return null
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DownloadsProvider on Android 10 may expose an audio document without its extension through
|
||||||
|
* DocumentFile.findFile(). Match the manifest first, then match the provider-normalized base
|
||||||
|
* filename. MIME type and server-reported size are unreliable for Opus on this platform.
|
||||||
|
*/
|
||||||
|
private fun resolveExternalFile(folder: DocumentFile?, part: DownloadItemPart): DocumentFile? {
|
||||||
|
if (folder == null) return null
|
||||||
|
folder.findFile(part.filename)?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val expectedBaseName = part.filename.substringBeforeLast('.')
|
||||||
|
return folder.listFiles().firstOrNull { document ->
|
||||||
|
document.name == part.filename ||
|
||||||
|
document.fullName == part.filename ||
|
||||||
|
(part.audioTrack != null &&
|
||||||
|
document.isFile &&
|
||||||
|
(document.name ?: "").substringBeforeLast('.') == expectedBaseName) ||
|
||||||
|
(part.audioTrack != null &&
|
||||||
|
document.isFile &&
|
||||||
|
document.fullName.substringBeforeLast('.') == expectedBaseName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeTypeFor(part: DownloadItemPart): String? {
|
||||||
|
return part.audioTrack?.mimeType
|
||||||
|
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||||
|
"epub" -> "application/epub+zip"
|
||||||
|
"pdf" -> "application/pdf"
|
||||||
|
else -> "image/jpeg"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun scanDownloadItem(item: DownloadItem, callback: (DownloadItemScanResult?) -> Unit) {
|
||||||
|
if (item.isInternalStorage) {
|
||||||
|
val id = "local_${item.libraryItemId}"
|
||||||
|
val localItem =
|
||||||
|
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||||
|
?: newLocalLibraryItem(id, item, item.itemFolderPath, item.itemFolderPath, "")
|
||||||
|
scanParts(item, localItem, callback = callback)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val root = DocumentFileCompat.fromUri(ctx, Uri.parse(item.localFolder.contentUrl))
|
||||||
|
if (root == null) {
|
||||||
|
Log.e(tag, "Invalid SAF root: ${item.localFolder.contentUrl}")
|
||||||
|
callback(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val itemFolder = findFolderByPath(root, item.itemSubfolder)
|
||||||
|
if (itemFolder == null) {
|
||||||
|
Log.e(tag, "SAF item folder not found: ${item.itemSubfolder}")
|
||||||
|
callback(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val id = localLibraryItemId(itemFolder.id)
|
||||||
|
val localItem =
|
||||||
|
DeviceManager.dbManager.getLocalLibraryItem(id)
|
||||||
|
?: newLocalLibraryItem(
|
||||||
|
id,
|
||||||
|
item,
|
||||||
|
itemFolder.getBasePath(ctx),
|
||||||
|
itemFolder.getAbsolutePath(ctx),
|
||||||
|
itemFolder.uri.toString()
|
||||||
|
)
|
||||||
|
scanParts(item, localItem, itemFolder, callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,325 @@
|
||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.data.*
|
||||||
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLog
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
|
import io.paperdb.Paper
|
||||||
|
|
||||||
|
class DbManager {
|
||||||
|
val tag = "DbManager"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private var isDbInitialized = false
|
||||||
|
|
||||||
|
fun initialize(ctx: Context) {
|
||||||
|
if (isDbInitialized) return
|
||||||
|
Paper.init(ctx)
|
||||||
|
isDbInitialized = true
|
||||||
|
Log.i("DbManager", "Initialized Paper db")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDeviceData(): DeviceData {
|
||||||
|
return Paper.book("device").read("data")
|
||||||
|
?: DeviceData(mutableListOf(), null, DeviceSettings.default(), null)
|
||||||
|
}
|
||||||
|
fun saveDeviceData(deviceData: DeviceData) {
|
||||||
|
Paper.book("device").write("data", deviceData)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLocalLibraryItems(mediaType: String? = null): MutableList<LocalLibraryItem> {
|
||||||
|
val localLibraryItems: MutableList<LocalLibraryItem> = mutableListOf()
|
||||||
|
Paper.book("localLibraryItems").allKeys.forEach {
|
||||||
|
val localLibraryItem: LocalLibraryItem? = Paper.book("localLibraryItems").read(it)
|
||||||
|
if (localLibraryItem != null &&
|
||||||
|
(mediaType.isNullOrEmpty() || mediaType == localLibraryItem.mediaType)
|
||||||
|
) {
|
||||||
|
localLibraryItems.add(localLibraryItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return localLibraryItems
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLocalLibraryItemsInFolder(folderId: String): List<LocalLibraryItem> {
|
||||||
|
val localLibraryItems = getLocalLibraryItems()
|
||||||
|
return localLibraryItems.filter { it.folderId == folderId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLocalLibraryItemByLId(libraryItemId: String): LocalLibraryItem? {
|
||||||
|
return getLocalLibraryItems().find { it.libraryItemId == libraryItemId }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLocalLibraryItem(localLibraryItemId: String): LocalLibraryItem? {
|
||||||
|
return Paper.book("localLibraryItems").read(localLibraryItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLocalLibraryItemWithEpisode(podcastEpisodeId: String): LibraryItemWithEpisode? {
|
||||||
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
|
val localLibraryItem =
|
||||||
|
getLocalLibraryItems("podcast").find { localLibraryItem ->
|
||||||
|
val podcast = localLibraryItem.media as Podcast
|
||||||
|
podcastEpisode = podcast.episodes?.find { it.id == podcastEpisodeId }
|
||||||
|
podcastEpisode != null
|
||||||
|
}
|
||||||
|
return if (localLibraryItem != null) {
|
||||||
|
LibraryItemWithEpisode(localLibraryItem, podcastEpisode!!)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeLocalLibraryItem(localLibraryItemId: String) {
|
||||||
|
Paper.book("localLibraryItems").delete(localLibraryItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLocalLibraryItems(localLibraryItems: List<LocalLibraryItem>) {
|
||||||
|
localLibraryItems.map { Paper.book("localLibraryItems").write(it.id, it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLocalLibraryItem(localLibraryItem: LocalLibraryItem) {
|
||||||
|
Paper.book("localLibraryItems").write(localLibraryItem.id, localLibraryItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLocalFolder(localFolder: LocalFolder) {
|
||||||
|
Paper.book("localFolders").write(localFolder.id, localFolder)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getLocalFolder(folderId: String): LocalFolder? {
|
||||||
|
return Paper.book("localFolders").read(folderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAllLocalFolders(): List<LocalFolder> {
|
||||||
|
val localFolders: MutableList<LocalFolder> = mutableListOf()
|
||||||
|
Paper.book("localFolders").allKeys.forEach { localFolderId ->
|
||||||
|
Paper.book("localFolders").read<LocalFolder>(localFolderId)?.let { localFolders.add(it) }
|
||||||
|
}
|
||||||
|
return localFolders
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeLocalFolder(folderId: String) {
|
||||||
|
val localLibraryItems = getLocalLibraryItemsInFolder(folderId)
|
||||||
|
localLibraryItems.forEach { Paper.book("localLibraryItems").delete(it.id) }
|
||||||
|
Paper.book("localFolders").delete(folderId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveDownloadItem(downloadItem: DownloadItem) {
|
||||||
|
Paper.book("downloadItems").write(downloadItem.id, downloadItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeDownloadItem(downloadItemId: String) {
|
||||||
|
Paper.book("downloadItems").delete(downloadItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getDownloadItems(): List<DownloadItem> {
|
||||||
|
val downloadItems: MutableList<DownloadItem> = mutableListOf()
|
||||||
|
Paper.book("downloadItems").allKeys.forEach { downloadItemId ->
|
||||||
|
Paper.book("downloadItems").read<DownloadItem>(downloadItemId)?.let { downloadItems.add(it) }
|
||||||
|
}
|
||||||
|
return downloadItems
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLocalMediaProgress(mediaProgress: LocalMediaProgress) {
|
||||||
|
Paper.book("localMediaProgress").write(mediaProgress.id, mediaProgress)
|
||||||
|
}
|
||||||
|
// For books this will just be the localLibraryItemId for podcast episodes this will be
|
||||||
|
// "{localLibraryItemId}-{episodeId}"
|
||||||
|
fun getLocalMediaProgress(localMediaProgressId: String): LocalMediaProgress? {
|
||||||
|
return Paper.book("localMediaProgress").read(localMediaProgressId)
|
||||||
|
}
|
||||||
|
fun getAllLocalMediaProgress(): List<LocalMediaProgress> {
|
||||||
|
val mediaProgress: MutableList<LocalMediaProgress> = mutableListOf()
|
||||||
|
Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId ->
|
||||||
|
Paper.book("localMediaProgress").read<LocalMediaProgress>(localMediaProgressId)?.let {
|
||||||
|
mediaProgress.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mediaProgress
|
||||||
|
}
|
||||||
|
fun removeLocalMediaProgress(localMediaProgressId: String) {
|
||||||
|
Paper.book("localMediaProgress").delete(localMediaProgressId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeAllLocalMediaProgress() {
|
||||||
|
Paper.book("localMediaProgress").destroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure all local file ids still exist
|
||||||
|
fun cleanLocalLibraryItems(context: Context) {
|
||||||
|
val localLibraryItems = getLocalLibraryItems()
|
||||||
|
|
||||||
|
localLibraryItems.forEach { lli ->
|
||||||
|
var hasUpdates = false
|
||||||
|
|
||||||
|
// Check local files
|
||||||
|
lli.localFiles =
|
||||||
|
lli.localFiles.filter { localFile ->
|
||||||
|
val exists = localFile.exists(context)
|
||||||
|
if (!exists) {
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalLibraryItems: Local file ${localFile.absolutePath} was removed from library item ${lli.media.metadata.title}"
|
||||||
|
)
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
exists
|
||||||
|
} as
|
||||||
|
MutableList<LocalFile>
|
||||||
|
|
||||||
|
// Check audio tracks and episodes
|
||||||
|
if (lli.isPodcast) {
|
||||||
|
val podcast = lli.media as Podcast
|
||||||
|
podcast.episodes =
|
||||||
|
podcast.episodes?.filter { ep ->
|
||||||
|
if (lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } == null) {
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalLibraryItems: Podcast episode ${ep.title} was removed from library item ${lli.media.metadata.title}"
|
||||||
|
)
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
ep.audioTrack != null &&
|
||||||
|
lli.localFiles.find { lf -> lf.id == ep.audioTrack?.localFileId } != null
|
||||||
|
} as
|
||||||
|
MutableList<PodcastEpisode>
|
||||||
|
} else {
|
||||||
|
val book = lli.media as Book
|
||||||
|
book.tracks =
|
||||||
|
book.tracks?.filter { track ->
|
||||||
|
if (lli.localFiles.find { lf -> lf.id == track.localFileId } == null) {
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalLibraryItems: Audio track ${track.title} was removed from library item ${lli.media.metadata.title}"
|
||||||
|
)
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
lli.localFiles.find { lf -> lf.id == track.localFileId } != null
|
||||||
|
} as
|
||||||
|
MutableList<AudioTrack>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cover still there
|
||||||
|
lli.coverAbsolutePath?.let {
|
||||||
|
val coverExists =
|
||||||
|
lli.localFiles.any { localFile ->
|
||||||
|
localFile.absolutePath == it && localFile.exists(context)
|
||||||
|
}
|
||||||
|
if (!coverExists) {
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalLibraryItems: Cover $it was removed from library item ${lli.media.metadata.title}"
|
||||||
|
)
|
||||||
|
lli.coverAbsolutePath = null
|
||||||
|
lli.coverContentUrl = null
|
||||||
|
hasUpdates = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lli.serverConnectionConfigId == null) {
|
||||||
|
// Local-only item support was removed in app version 0.9.67, remove any remaining local
|
||||||
|
// only items beginning in 0.9.80
|
||||||
|
Log.d(tag, "cleanLocalLibraryItems: Local only item ${lli.id} - removing from ABS")
|
||||||
|
Paper.book("localLibraryItems").delete(lli.id)
|
||||||
|
} else if (hasUpdates) {
|
||||||
|
Log.d(tag, "cleanLocalLibraryItems: Saving local library item ${lli.id}")
|
||||||
|
Paper.book("localLibraryItems").write(lli.id, lli)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any local media progress where the local media item is not found
|
||||||
|
fun cleanLocalMediaProgress() {
|
||||||
|
val localMediaProgress = getAllLocalMediaProgress()
|
||||||
|
val localLibraryItems = getLocalLibraryItems()
|
||||||
|
localMediaProgress.forEach {
|
||||||
|
val matchingLLI = localLibraryItems.find { lli -> lli.id == it.localLibraryItemId }
|
||||||
|
if (!it.id.startsWith("local")) {
|
||||||
|
// A bug on the server when syncing local media progress was replacing the media progress id
|
||||||
|
// causing duplicate progress. Remove them.
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalMediaProgress: Invalid local media progress does not start with 'local' (fixed on server 2.0.24)"
|
||||||
|
)
|
||||||
|
Paper.book("localMediaProgress").delete(it.id)
|
||||||
|
} else if (matchingLLI == null) {
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalMediaProgress: No matching local library item for local media progress ${it.id} - removing"
|
||||||
|
)
|
||||||
|
Paper.book("localMediaProgress").delete(it.id)
|
||||||
|
} else if (matchingLLI.isPodcast) {
|
||||||
|
if (it.localEpisodeId.isNullOrEmpty()) {
|
||||||
|
Log.d(tag, "cleanLocalMediaProgress: Podcast media progress has no episode id - removing")
|
||||||
|
Paper.book("localMediaProgress").delete(it.id)
|
||||||
|
} else {
|
||||||
|
val podcast = matchingLLI.media as Podcast
|
||||||
|
val matchingLEp = podcast.episodes?.find { ep -> ep.id == it.localEpisodeId }
|
||||||
|
if (matchingLEp == null) {
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"cleanLocalMediaProgress: Podcast media progress for episode ${it.localEpisodeId} not found - removing"
|
||||||
|
)
|
||||||
|
Paper.book("localMediaProgress").delete(it.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveMediaItemHistory(mediaItemHistory: MediaItemHistory) {
|
||||||
|
Paper.book("mediaItemHistory").write(mediaItemHistory.id, mediaItemHistory)
|
||||||
|
}
|
||||||
|
fun getMediaItemHistory(id: String): MediaItemHistory? {
|
||||||
|
return Paper.book("mediaItemHistory").read(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun savePlaybackSession(playbackSession: PlaybackSession) {
|
||||||
|
Paper.book("playbackSession").write(playbackSession.id, playbackSession)
|
||||||
|
}
|
||||||
|
fun removePlaybackSession(playbackSessionId: String) {
|
||||||
|
Paper.book("playbackSession").delete(playbackSessionId)
|
||||||
|
}
|
||||||
|
fun getPlaybackSessions(): List<PlaybackSession> {
|
||||||
|
val sessions: MutableList<PlaybackSession> = mutableListOf()
|
||||||
|
Paper.book("playbackSession").allKeys.forEach { playbackSessionId ->
|
||||||
|
Paper.book("playbackSession").read<PlaybackSession>(playbackSessionId)?.let {
|
||||||
|
sessions.add(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveLog(log: AbsLog) {
|
||||||
|
Paper.book("log").write(log.id, log)
|
||||||
|
}
|
||||||
|
fun getAllLogs(): List<AbsLog> {
|
||||||
|
val logs: MutableList<AbsLog> = mutableListOf()
|
||||||
|
Paper.book("log").allKeys.forEach { logId ->
|
||||||
|
Paper.book("log").read<AbsLog>(logId)?.let { logs.add(it) }
|
||||||
|
}
|
||||||
|
return logs.sortedBy { it.timestamp }
|
||||||
|
}
|
||||||
|
fun removeAllLogs() {
|
||||||
|
Paper.book("log").destroy()
|
||||||
|
}
|
||||||
|
fun cleanLogs() {
|
||||||
|
val numberOfHoursToKeep = 48
|
||||||
|
val keepLogCutoff = System.currentTimeMillis() - (3600000 * numberOfHoursToKeep)
|
||||||
|
val allLogs = getAllLogs()
|
||||||
|
var logsRemoved = 0
|
||||||
|
allLogs.forEach {
|
||||||
|
if (it.timestamp < keepLogCutoff) {
|
||||||
|
Paper.book("log").delete(it.id)
|
||||||
|
logsRemoved++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (logsRemoved > 0) {
|
||||||
|
AbsLogger.info(
|
||||||
|
"DbManager",
|
||||||
|
"cleanLogs: Removed $logsRemoved logs older than $numberOfHoursToKeep hours"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,794 @@
|
||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.StatFs
|
||||||
|
import androidx.documentfile.provider.DocumentFile
|
||||||
|
import com.anggrayudi.storage.file.fullName
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.device.FolderScanner
|
||||||
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import com.audiobookshelf.app.models.DownloadItemPart
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import com.fasterxml.jackson.core.json.JsonReadFeature
|
||||||
|
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
|
||||||
|
import com.getcapacitor.JSObject
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileInputStream
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
/** Manages the process-owned queue for app-managed downloads. */
|
||||||
|
class DownloadItemManager(
|
||||||
|
private val folderScanner: FolderScanner,
|
||||||
|
private val context: Context,
|
||||||
|
private var clientEventEmitter: DownloadEventEmitter
|
||||||
|
) {
|
||||||
|
private val tag = "DownloadItemManager"
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
private val activeCalls = ConcurrentHashMap<String, InternalDownloadManager.DownloadHandle>()
|
||||||
|
private val safFolderLocks = ConcurrentHashMap<String, Any>()
|
||||||
|
private val scanLocks = ConcurrentHashMap<String, Any>()
|
||||||
|
private val reservations = mutableMapOf<String, Long>()
|
||||||
|
private val lastPersistTime = mutableMapOf<String, Long>()
|
||||||
|
private val finalizingItems = mutableSetOf<String>()
|
||||||
|
private val refreshingServerIds = mutableSetOf<String>()
|
||||||
|
private val apiHandler = ApiHandler(context)
|
||||||
|
private var watcherRunning = false
|
||||||
|
private val jacksonMapper =
|
||||||
|
jacksonObjectMapper()
|
||||||
|
.enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature())
|
||||||
|
|
||||||
|
var downloadItemQueue: MutableList<DownloadItem> = mutableListOf()
|
||||||
|
private set
|
||||||
|
var currentDownloadItemParts: MutableList<DownloadItemPart> = mutableListOf()
|
||||||
|
private set
|
||||||
|
|
||||||
|
interface DownloadEventEmitter {
|
||||||
|
fun onDownloadItem(downloadItem: DownloadItem)
|
||||||
|
fun onDownloadItemPartUpdate(downloadItemPart: DownloadItemPart)
|
||||||
|
fun onDownloadItemComplete(jsobj: JSObject)
|
||||||
|
fun onQueueChanged(hasWork: Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface InternalProgressCallback {
|
||||||
|
fun onSizeResolved(totalBytes: Long)
|
||||||
|
fun onProgress(totalBytesWritten: Long, progress: Long)
|
||||||
|
fun onComplete(failed: Boolean)
|
||||||
|
fun onAuthError()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun setEventEmitter(eventEmitter: DownloadEventEmitter) {
|
||||||
|
clientEventEmitter = eventEmitter
|
||||||
|
downloadItemQueue.forEach(clientEventEmitter::onDownloadItem)
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun restoreQueue() {
|
||||||
|
if (downloadItemQueue.isNotEmpty()) return
|
||||||
|
DeviceManager.dbManager.getDownloadItems().forEach { item ->
|
||||||
|
item.downloadItemParts.filter { it.moved }.forEach { part ->
|
||||||
|
if (!finalizedFileExists(part)) {
|
||||||
|
AbsLogger.error(tag, "Finalized file is missing; resetting ${part.filename}")
|
||||||
|
part.moved = false
|
||||||
|
part.completed = false
|
||||||
|
part.completedDestinationUri = null
|
||||||
|
part.downloadId = null
|
||||||
|
part.reusedExistingFile = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.isDownloadFinished) {
|
||||||
|
downloadItemQueue.add(item)
|
||||||
|
checkDownloadItemFinished(item)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
var resetFailed = false
|
||||||
|
item.downloadItemParts.forEach { part ->
|
||||||
|
if (part.moved) return@forEach
|
||||||
|
if (!resetPartForFreshDownload(part)) resetFailed = true
|
||||||
|
}
|
||||||
|
if (resetFailed) {
|
||||||
|
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||||
|
item.stagingCleanupAt = null
|
||||||
|
}
|
||||||
|
if (item.terminalFailureAt != null) {
|
||||||
|
item.downloadItemParts.filter { !it.moved }.forEach { it.failed = true }
|
||||||
|
}
|
||||||
|
downloadItemQueue.add(item)
|
||||||
|
if (item.terminalFailureAt != null) IncompleteDownloadCleanup.schedule(context, item)
|
||||||
|
clientEventEmitter.onDownloadItem(item)
|
||||||
|
}
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun addDownloadItem(downloadItem: DownloadItem) {
|
||||||
|
val existingItem = downloadItemQueue.find { it.id == downloadItem.id }
|
||||||
|
if (existingItem != null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
persist(downloadItem, force = true)
|
||||||
|
downloadItemQueue.add(downloadItem)
|
||||||
|
clientEventEmitter.onDownloadItem(downloadItem)
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun retryDownloadItem(downloadItemId: String): Boolean {
|
||||||
|
val item = downloadItemQueue.find { it.id == downloadItemId } ?: return false
|
||||||
|
if (item.downloadItemParts.any { it in currentDownloadItemParts }) return false
|
||||||
|
if (item.isDownloadFinished) return false
|
||||||
|
synchronized(IncompleteDownloadCleanup) {
|
||||||
|
var resetFailed = false
|
||||||
|
item.downloadItemParts.filter { !it.moved }.forEach { part ->
|
||||||
|
if (!resetPartForFreshDownload(part)) resetFailed = true
|
||||||
|
}
|
||||||
|
if (resetFailed) {
|
||||||
|
item.downloadItemParts.filter { !it.moved }.forEach { it.failed = true }
|
||||||
|
persist(item, force = true)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
item.terminalFailureAt = null
|
||||||
|
item.stagingCleanupAt = null
|
||||||
|
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||||
|
persist(item, force = true)
|
||||||
|
}
|
||||||
|
clientEventEmitter.onDownloadItem(item)
|
||||||
|
notifyQueueChanged()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun resumeWork() {
|
||||||
|
checkUpdateDownloadQueue()
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun cancelAll() {
|
||||||
|
activeCalls.values.forEach(InternalDownloadManager.DownloadHandle::cancel)
|
||||||
|
activeCalls.clear()
|
||||||
|
downloadItemQueue.forEach { item ->
|
||||||
|
item.downloadItemParts.forEach { part ->
|
||||||
|
File(part.destinationPath).delete()
|
||||||
|
if (part.moved && !part.reusedExistingFile && part.isInternalStorage) {
|
||||||
|
File(part.finalDestinationPath).delete()
|
||||||
|
} else if (part.moved && !part.reusedExistingFile) {
|
||||||
|
part.completedDestinationUri?.let { uri ->
|
||||||
|
try {
|
||||||
|
DocumentFile.fromSingleUri(context, Uri.parse(uri))?.delete()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AbsLogger.error(
|
||||||
|
tag,
|
||||||
|
"Could not delete cancelled SAF file ${part.filename}: ${e.message}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||||
|
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||||
|
}
|
||||||
|
currentDownloadItemParts.clear()
|
||||||
|
reservations.clear()
|
||||||
|
downloadItemQueue.clear()
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun hasWork(): Boolean =
|
||||||
|
finalizingItems.isNotEmpty() ||
|
||||||
|
downloadItemQueue.any { item ->
|
||||||
|
item.downloadItemParts.any { part ->
|
||||||
|
(!part.moved && !part.failed) || part.isMoving
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun checkUpdateDownloadQueue() {
|
||||||
|
downloadItemQueue.toList().forEach { item ->
|
||||||
|
var slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||||
|
if (slots <= 0) return@forEach
|
||||||
|
item.downloadItemParts
|
||||||
|
.filter { part ->
|
||||||
|
part.completed &&
|
||||||
|
!part.moved &&
|
||||||
|
!part.failed &&
|
||||||
|
!part.isMoving &&
|
||||||
|
part !in currentDownloadItemParts &&
|
||||||
|
File(part.destinationPath).exists() &&
|
||||||
|
!hasActiveDestinationConflict(part)
|
||||||
|
}
|
||||||
|
.take(slots)
|
||||||
|
.forEach { part ->
|
||||||
|
currentDownloadItemParts.add(part)
|
||||||
|
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||||
|
}
|
||||||
|
slots = MAX_SIMULTANEOUS_DOWNLOADS - currentDownloadItemParts.size
|
||||||
|
if (slots <= 0) return@forEach
|
||||||
|
item.getNextDownloadItemParts(slots).forEach { part ->
|
||||||
|
val existingFile = findSharedStorageFile(part)
|
||||||
|
if (existingFile != null) {
|
||||||
|
part.bytesDownloaded = existingFile.length()
|
||||||
|
part.progress = 100L
|
||||||
|
part.completedDestinationUri = existingFile.uri.toString()
|
||||||
|
part.reusedExistingFile = true
|
||||||
|
File(part.destinationPath).delete()
|
||||||
|
completePart(item, part)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (completeFromExistingInternalCover(item, part)) return@forEach
|
||||||
|
if (hasActiveDestinationConflict(part)) {
|
||||||
|
leaveQueued(item, part)
|
||||||
|
} else if (part.fileSize <= 0L && currentDownloadItemParts.any { it.fileSize <= 0L }) {
|
||||||
|
leaveQueued(item, part)
|
||||||
|
} else if (tryReserve(part)) startDownload(item, part)
|
||||||
|
else {
|
||||||
|
part.waitingForSpace = true
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hasWork()) startWatchingDownloads() else notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startDownload(item: DownloadItem, part: DownloadItemPart) {
|
||||||
|
val stagingFile = File(part.destinationPath)
|
||||||
|
stagingFile.parentFile?.mkdirs()
|
||||||
|
part.downloadId = APP_MANAGED_DOWNLOAD_ID
|
||||||
|
part.waitingForSpace = false
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
currentDownloadItemParts.add(part)
|
||||||
|
persist(item, force = true)
|
||||||
|
AbsLogger.info(tag, "Starting download for ${part.filename}")
|
||||||
|
val activeConfig = DeviceManager.serverConnectionConfig
|
||||||
|
val token =
|
||||||
|
if (activeConfig?.id == item.serverConnectionConfigId) activeConfig.token
|
||||||
|
else
|
||||||
|
DeviceManager.getServerConnectionConfig(item.serverConnectionConfigId)?.token
|
||||||
|
?: DeviceManager.token
|
||||||
|
val handle =
|
||||||
|
InternalDownloadManager(
|
||||||
|
stagingFile,
|
||||||
|
part.fileSize,
|
||||||
|
object : InternalProgressCallback {
|
||||||
|
override fun onSizeResolved(totalBytes: Long) {
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
if (part !in currentDownloadItemParts || totalBytes < 0L) return
|
||||||
|
if (part.fileSize == totalBytes) return
|
||||||
|
AbsLogger.info(
|
||||||
|
tag,
|
||||||
|
"Using server size $totalBytes instead of metadata size ${part.fileSize} for ${part.filename}"
|
||||||
|
)
|
||||||
|
part.fileSize = totalBytes
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item, force = true)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onProgress(totalBytesWritten: Long, progress: Long) {
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
if (part !in currentDownloadItemParts) return
|
||||||
|
part.bytesDownloaded = totalBytesWritten
|
||||||
|
part.progress = progress
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
persist(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onComplete(failed: Boolean) {
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
if (part !in currentDownloadItemParts) return
|
||||||
|
part.failed = failed
|
||||||
|
part.completed = !failed
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
activeCalls.remove(part.id)
|
||||||
|
persist(item, force = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAuthError() {
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
if (part !in currentDownloadItemParts) return
|
||||||
|
handleAuthError(item, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ hasAvailableSpace(part) }
|
||||||
|
)
|
||||||
|
.download(serverUrl(item, part), token)
|
||||||
|
if (part in currentDownloadItemParts && !part.completed && !part.failed) {
|
||||||
|
activeCalls[part.id] = handle
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun startWatchingDownloads() {
|
||||||
|
if (watcherRunning) return
|
||||||
|
watcherRunning = true
|
||||||
|
scope.launch {
|
||||||
|
while (true) {
|
||||||
|
val activeParts =
|
||||||
|
synchronized(this@DownloadItemManager) { currentDownloadItemParts.toList() }
|
||||||
|
activeParts.forEach(::handlePartUpdate)
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
checkUpdateDownloadQueue()
|
||||||
|
if (!hasWork()) {
|
||||||
|
watcherRunning = false
|
||||||
|
notifyQueueChanged()
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delay(WATCH_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handlePartUpdate(part: DownloadItemPart) {
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
val item =
|
||||||
|
synchronized(this) { downloadItemQueue.find { it.id == part.downloadItemId } }
|
||||||
|
?: run {
|
||||||
|
removeActivePart(part)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!part.completed && !part.failed) {
|
||||||
|
val lastUpdate = part.lastUpdateTime ?: return
|
||||||
|
if (System.currentTimeMillis() - lastUpdate > STALL_TIMEOUT_MS) {
|
||||||
|
AbsLogger.error(tag, "Download stalled: ${part.filename}")
|
||||||
|
activeCalls.remove(part.id)?.cancel()
|
||||||
|
failOrRetry(item, part, "Download stalled")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (part.failed) {
|
||||||
|
failOrRetry(item, part, "Transfer failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (part.isInternalStorage) finalizeInternalFile(item, part) else moveDownloadedFile(item, part)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun failOrRetry(item: DownloadItem, part: DownloadItemPart, reason: String) {
|
||||||
|
removeActivePart(part)
|
||||||
|
part.retryCount += 1
|
||||||
|
reservations.remove(part.destinationPath)
|
||||||
|
if (part.retryCount > MAX_RETRIES) {
|
||||||
|
markTerminalFailure(item, part, "$reason after $MAX_RETRIES retries")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
part.failed = false
|
||||||
|
part.completed = false
|
||||||
|
part.downloadId = null
|
||||||
|
part.isMoving = false
|
||||||
|
persist(item, force = true)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A 401 refreshes the token for this queued item's server without consuming transfer retries. */
|
||||||
|
@Synchronized
|
||||||
|
private fun handleAuthError(item: DownloadItem, part: DownloadItemPart) {
|
||||||
|
removeActivePart(part)
|
||||||
|
reservations.remove(part.destinationPath)
|
||||||
|
part.downloadId = null
|
||||||
|
part.isMoving = false
|
||||||
|
part.failed = false
|
||||||
|
part.completed = false
|
||||||
|
part.authRetryCount += 1
|
||||||
|
part.lastUpdateTime = System.currentTimeMillis()
|
||||||
|
if (part.authRetryCount > MAX_AUTH_RETRIES) {
|
||||||
|
markTerminalFailure(item, part, "Unauthorized after $MAX_AUTH_RETRIES token refresh attempts")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
AbsLogger.info(
|
||||||
|
tag,
|
||||||
|
"Refreshing token after 401 for ${part.filename} (attempt ${part.authRetryCount})"
|
||||||
|
)
|
||||||
|
persist(item, force = true)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
refreshTokenThenResume(item.serverConnectionConfigId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshTokenThenResume(serverConnectionConfigId: String) {
|
||||||
|
if (!refreshingServerIds.add(serverConnectionConfigId)) return
|
||||||
|
apiHandler.refreshAuthTokens(serverConnectionConfigId) { result ->
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
refreshingServerIds.remove(serverConnectionConfigId)
|
||||||
|
when (result) {
|
||||||
|
is ApiHandler.RefreshResult.Success -> {
|
||||||
|
AbsLogger.info(
|
||||||
|
tag,
|
||||||
|
"Token refresh succeeded; resuming downloads for $serverConnectionConfigId"
|
||||||
|
)
|
||||||
|
checkUpdateDownloadQueue()
|
||||||
|
}
|
||||||
|
ApiHandler.RefreshResult.Rejected -> failParkedAuthParts(serverConnectionConfigId)
|
||||||
|
// Parked parts are still queued, so MAX_AUTH_RETRIES bounds the reattempts.
|
||||||
|
ApiHandler.RefreshResult.Transient -> {
|
||||||
|
AbsLogger.info(
|
||||||
|
tag,
|
||||||
|
"Token refresh could not be completed; retrying downloads for $serverConnectionConfigId"
|
||||||
|
)
|
||||||
|
checkUpdateDownloadQueue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun failParkedAuthParts(serverConnectionConfigId: String) {
|
||||||
|
downloadItemQueue.toList().forEach { item ->
|
||||||
|
if (item.serverConnectionConfigId != serverConnectionConfigId) return@forEach
|
||||||
|
item.downloadItemParts
|
||||||
|
.filter {
|
||||||
|
it.authRetryCount > 0 &&
|
||||||
|
!it.completed &&
|
||||||
|
!it.failed &&
|
||||||
|
it.downloadId == null &&
|
||||||
|
it !in currentDownloadItemParts
|
||||||
|
}
|
||||||
|
.forEach { part ->
|
||||||
|
markTerminalFailure(item, part, "Unable to refresh download authorization")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun markTerminalFailure(item: DownloadItem, part: DownloadItemPart, reason: String) {
|
||||||
|
AbsLogger.error(tag, "$reason: ${part.filename}")
|
||||||
|
removeActivePart(part)
|
||||||
|
reservations.remove(part.destinationPath)
|
||||||
|
part.failed = true
|
||||||
|
part.completed = false
|
||||||
|
part.downloadId = null
|
||||||
|
part.isMoving = false
|
||||||
|
item.terminalFailureAt = item.terminalFailureAt ?: System.currentTimeMillis()
|
||||||
|
item.stagingCleanupAt = null
|
||||||
|
persist(item, force = true)
|
||||||
|
IncompleteDownloadCleanup.schedule(context, item)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun finalizeInternalFile(item: DownloadItem, part: DownloadItemPart) {
|
||||||
|
if (part.moved || part.isMoving) return
|
||||||
|
part.isMoving = true
|
||||||
|
val stagingFile = File(part.destinationPath)
|
||||||
|
val finalFile = File(part.finalDestinationPath)
|
||||||
|
finalFile.parentFile?.mkdirs()
|
||||||
|
val backup = File(finalFile.parentFile, ".${finalFile.name}.abs-backup")
|
||||||
|
try {
|
||||||
|
if (backup.exists() && !backup.delete()) throw IllegalStateException("Could not clear backup")
|
||||||
|
if (finalFile.exists() && !finalFile.renameTo(backup))
|
||||||
|
throw IllegalStateException("Could not protect existing file")
|
||||||
|
if (!stagingFile.renameTo(finalFile)) {
|
||||||
|
if (backup.exists()) backup.renameTo(finalFile)
|
||||||
|
throw IllegalStateException("Could not finalize internal staging file")
|
||||||
|
}
|
||||||
|
backup.delete()
|
||||||
|
AbsLogger.info(tag, "Move completed for ${part.filename} to ${finalFile.absolutePath}")
|
||||||
|
completePart(item, part)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
part.isMoving = false
|
||||||
|
part.failed = true
|
||||||
|
failOrRetry(item, part, e.message ?: "Internal finalization failed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun moveDownloadedFile(item: DownloadItem, part: DownloadItemPart) {
|
||||||
|
if (part.moved || part.isMoving) return
|
||||||
|
val root =
|
||||||
|
DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl))
|
||||||
|
?: return failFinalization(item, part, "Could not resolve SAF destination")
|
||||||
|
part.isMoving = true
|
||||||
|
persist(item, force = true)
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
if (!hasAvailableSpace(part))
|
||||||
|
throw IllegalStateException("Insufficient storage for SAF copy")
|
||||||
|
val folderKey = "${root.uri}/${part.finalDestinationSubfolder}"
|
||||||
|
val folderLock = safFolderLocks.computeIfAbsent(folderKey) { Any() }
|
||||||
|
val folder =
|
||||||
|
synchronized(folderLock) { getOrCreateFolder(root, part.finalDestinationSubfolder) }
|
||||||
|
?: throw IllegalStateException("Could not create SAF destination folder")
|
||||||
|
val temporaryName = ".${part.filename}.${part.id.hashCode()}.part"
|
||||||
|
folder.findFile(temporaryName)?.delete()
|
||||||
|
val temporary =
|
||||||
|
folder.createFile(mimeTypeFor(part), temporaryName)
|
||||||
|
?: throw IllegalStateException("Could not create SAF temporary file")
|
||||||
|
val staging = File(part.destinationPath)
|
||||||
|
FileInputStream(staging).use { input ->
|
||||||
|
context.contentResolver.openOutputStream(temporary.uri, "w")?.use { input.copyTo(it) }
|
||||||
|
?: throw IllegalStateException("Could not open SAF output stream")
|
||||||
|
}
|
||||||
|
if (temporary.length() != staging.length())
|
||||||
|
throw IllegalStateException("SAF copy size mismatch")
|
||||||
|
val existing = findDocumentByFilename(folder, part)
|
||||||
|
if (existing != null && !existing.delete())
|
||||||
|
throw IllegalStateException("Could not replace existing file")
|
||||||
|
if (!temporary.renameTo(part.filename))
|
||||||
|
throw IllegalStateException("Could not finalize SAF temporary file")
|
||||||
|
val destination =
|
||||||
|
findDocumentByFilename(folder, part)
|
||||||
|
?: throw IllegalStateException("Could not reopen finalized SAF file")
|
||||||
|
if (destination.length() != staging.length())
|
||||||
|
throw IllegalStateException("SAF final size mismatch")
|
||||||
|
if (!staging.delete()) AbsLogger.error(tag, "Could not remove staging file ${staging.name}")
|
||||||
|
part.completedDestinationUri = destination.uri.toString()
|
||||||
|
AbsLogger.info(tag, "Move completed for ${part.filename} to ${destination.uri}")
|
||||||
|
completePart(item, part)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
failFinalization(item, part, "SAF copy failed: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun failFinalization(item: DownloadItem, part: DownloadItemPart, message: String) {
|
||||||
|
AbsLogger.error(tag, message)
|
||||||
|
part.isMoving = false
|
||||||
|
part.failed = true
|
||||||
|
failOrRetry(item, part, message)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun completePart(item: DownloadItem, part: DownloadItemPart) {
|
||||||
|
part.moved = true
|
||||||
|
part.completed = true
|
||||||
|
part.failed = false
|
||||||
|
part.isMoving = false
|
||||||
|
reservations.remove(part.destinationPath)
|
||||||
|
removeActivePart(part)
|
||||||
|
persist(item, force = true)
|
||||||
|
checkDownloadItemFinished(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun checkDownloadItemFinished(item: DownloadItem) {
|
||||||
|
if (!item.isDownloadFinished || !finalizingItems.add(item.id)) return
|
||||||
|
IncompleteDownloadCleanup.cancel(context, item.id)
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val scanLock = scanLocks.computeIfAbsent(scanDestinationKey(item)) { Any() }
|
||||||
|
synchronized(scanLock) {
|
||||||
|
folderScanner.scanDownloadItem(item) { scanResult ->
|
||||||
|
val event =
|
||||||
|
JSObject().apply {
|
||||||
|
put("libraryItemId", item.id)
|
||||||
|
put("localFolderId", item.localFolder.id)
|
||||||
|
scanResult?.localLibraryItem?.let {
|
||||||
|
put("localLibraryItem", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
|
}
|
||||||
|
scanResult?.localMediaProgress?.let {
|
||||||
|
put("localMediaProgress", JSObject(jacksonMapper.writeValueAsString(it)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
clientEventEmitter.onDownloadItemComplete(event)
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
downloadItemQueue.remove(item)
|
||||||
|
DeviceManager.dbManager.removeDownloadItem(item.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// The files are already in place, so leave the item queued for restoreQueue to rescan.
|
||||||
|
AbsLogger.error(tag, "Could not finalize download item ${item.id}: ${e.message}")
|
||||||
|
} finally {
|
||||||
|
synchronized(this@DownloadItemManager) {
|
||||||
|
finalizingItems.remove(item.id)
|
||||||
|
notifyQueueChanged()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryReserve(part: DownloadItemPart): Boolean {
|
||||||
|
val staging = File(part.destinationPath)
|
||||||
|
staging.parentFile?.mkdirs()
|
||||||
|
val expectedSize = if (part.fileSize > 0L) part.fileSize else UNKNOWN_PART_RESERVATION_BYTES
|
||||||
|
val remaining =
|
||||||
|
(expectedSize - (staging.takeIf(File::exists)?.length() ?: 0L)).coerceAtLeast(0L)
|
||||||
|
val required = if (part.isInternalStorage) remaining else remaining + expectedSize
|
||||||
|
val key = storageKey(staging)
|
||||||
|
val fs = statFsFor(staging)
|
||||||
|
val headroom = max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||||
|
val alreadyReserved = reservations.filterKeys { storageKey(File(it)) == key }.values.sum()
|
||||||
|
if (fs.availableBytes - alreadyReserved < required + headroom) return false
|
||||||
|
reservations[part.destinationPath] = required
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasAvailableSpace(part: DownloadItemPart): Boolean {
|
||||||
|
val staging = File(part.destinationPath)
|
||||||
|
val fs = statFsFor(staging)
|
||||||
|
return fs.availableBytes >= max(MIN_FREE_SPACE_BYTES, fs.totalBytes / 20L)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun statFsFor(staging: File): StatFs {
|
||||||
|
var directory = staging.parentFile ?: context.filesDir
|
||||||
|
directory.mkdirs()
|
||||||
|
while (!directory.exists()) directory = directory.parentFile ?: context.filesDir
|
||||||
|
return StatFs(directory.absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun storageKey(file: File): String =
|
||||||
|
if (file.absolutePath.startsWith(context.filesDir.absolutePath)) "internal"
|
||||||
|
else "external"
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun removeActivePart(part: DownloadItemPart) {
|
||||||
|
activeCalls.remove(part.id)
|
||||||
|
currentDownloadItemParts.remove(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun persist(item: DownloadItem, force: Boolean = false) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!force && now - (lastPersistTime[item.id] ?: 0L) < PERSIST_INTERVAL_MS) return
|
||||||
|
lastPersistTime[item.id] = now
|
||||||
|
DeviceManager.dbManager.saveDownloadItem(item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun notifyQueueChanged() {
|
||||||
|
clientEventEmitter.onQueueChanged(hasWork())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun destroy() {
|
||||||
|
activeCalls.values.forEach(InternalDownloadManager.DownloadHandle::cancel)
|
||||||
|
activeCalls.clear()
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateFolder(root: DocumentFile, relativePath: String): DocumentFile? {
|
||||||
|
var current = root
|
||||||
|
relativePath.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||||
|
if (segment == "." || segment == "..") return null
|
||||||
|
current = current.findFile(segment) ?: current.createDirectory(segment) ?: return null
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findSharedStorageFile(part: DownloadItemPart): DocumentFile? {
|
||||||
|
if (part.isInternalStorage) return null
|
||||||
|
val root = DocumentFile.fromTreeUri(context, Uri.parse(part.localFolderUrl)) ?: return null
|
||||||
|
var folder = root
|
||||||
|
part.finalDestinationSubfolder.split('/').filter { it.isNotBlank() }.forEach { segment ->
|
||||||
|
if (segment == "." || segment == "..") return null
|
||||||
|
folder = folder.findFile(segment) ?: return null
|
||||||
|
}
|
||||||
|
val file = findDocumentByFilename(folder, part) ?: return null
|
||||||
|
if (!file.isFile) return null
|
||||||
|
if (part.fileSize > 0L && file.length() != part.fileSize) return null
|
||||||
|
if (part.fileSize <= 0L && file.length() <= 0L) return null
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun completeFromExistingInternalCover(
|
||||||
|
item: DownloadItem,
|
||||||
|
part: DownloadItemPart
|
||||||
|
): Boolean {
|
||||||
|
if (!part.isInternalStorage || !part.serverPath.endsWith("/cover")) return false
|
||||||
|
val file = File(part.finalDestinationPath)
|
||||||
|
if (!file.isFile || file.length() <= 0L) return false
|
||||||
|
if (part.fileSize > 0L && file.length() != part.fileSize) return false
|
||||||
|
part.bytesDownloaded = file.length()
|
||||||
|
part.progress = 100L
|
||||||
|
part.reusedExistingFile = true
|
||||||
|
AbsLogger.info(tag, "Reusing existing cover ${part.filename}")
|
||||||
|
File(part.destinationPath).delete()
|
||||||
|
completePart(item, part)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasActiveDestinationConflict(part: DownloadItemPart): Boolean =
|
||||||
|
currentDownloadItemParts.any { activePart ->
|
||||||
|
activePart !== part &&
|
||||||
|
activePart.localFolderId == part.localFolderId &&
|
||||||
|
activePart.finalDestinationPath == part.finalDestinationPath
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun leaveQueued(item: DownloadItem, part: DownloadItemPart) {
|
||||||
|
if (!part.waitingForSpace) return
|
||||||
|
part.waitingForSpace = false
|
||||||
|
part.downloadId = null
|
||||||
|
persist(item)
|
||||||
|
clientEventEmitter.onDownloadItemPartUpdate(part)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scanDestinationKey(item: DownloadItem): String =
|
||||||
|
"${item.localFolder.id}:${item.itemFolderPath}"
|
||||||
|
|
||||||
|
private fun finalizedFileExists(part: DownloadItemPart): Boolean {
|
||||||
|
if (part.isInternalStorage) {
|
||||||
|
val file = File(part.finalDestinationPath)
|
||||||
|
return file.isFile &&
|
||||||
|
if (part.fileSize > 0L) file.length() == part.fileSize else file.length() > 0L
|
||||||
|
}
|
||||||
|
part.completedDestinationUri?.let { uri ->
|
||||||
|
try {
|
||||||
|
val file = DocumentFile.fromSingleUri(context, Uri.parse(uri))
|
||||||
|
if (file?.isFile == true && (part.fileSize <= 0L || file.length() == part.fileSize))
|
||||||
|
return true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
AbsLogger.error(tag, "Could not validate SAF file ${part.filename}: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findSharedStorageFile(part) != null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resets an unmoved part when recovery crosses a service-session boundary. */
|
||||||
|
private fun resetPartForFreshDownload(part: DownloadItemPart): Boolean {
|
||||||
|
val stagingFile = File(part.destinationPath)
|
||||||
|
if (stagingFile.exists() && !stagingFile.delete()) {
|
||||||
|
AbsLogger.error(tag, "Could not delete staging file ${part.filename}")
|
||||||
|
part.failed = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
part.completed = false
|
||||||
|
part.bytesDownloaded = 0L
|
||||||
|
part.progress = 0L
|
||||||
|
part.failed = false
|
||||||
|
part.isMoving = false
|
||||||
|
part.downloadId = null
|
||||||
|
part.retryCount = 0
|
||||||
|
part.authRetryCount = 0
|
||||||
|
part.waitingForSpace = false
|
||||||
|
part.reusedExistingFile = false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findDocumentByFilename(folder: DocumentFile, part: DownloadItemPart): DocumentFile? {
|
||||||
|
folder.findFile(part.filename)?.let {
|
||||||
|
return it
|
||||||
|
}
|
||||||
|
val expectedBaseName = part.filename.substringBeforeLast('.')
|
||||||
|
return folder.listFiles().firstOrNull { document ->
|
||||||
|
document.name == part.filename ||
|
||||||
|
document.fullName == part.filename ||
|
||||||
|
(part.audioTrack != null &&
|
||||||
|
document.isFile &&
|
||||||
|
((document.name ?: "").substringBeforeLast('.') == expectedBaseName ||
|
||||||
|
document.fullName.substringBeforeLast('.') == expectedBaseName))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mimeTypeFor(part: DownloadItemPart): String =
|
||||||
|
part.audioTrack?.mimeType
|
||||||
|
?: when (part.ebookFile?.ebookFormat?.lowercase()) {
|
||||||
|
"epub" -> "application/epub+zip"
|
||||||
|
"pdf" -> "application/pdf"
|
||||||
|
else -> "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serverUrl(item: DownloadItem, part: DownloadItemPart): String {
|
||||||
|
val rawCover = if (part.serverPath.endsWith("/cover")) "?raw=1" else ""
|
||||||
|
return "${item.serverAddress}${part.serverPath}$rawCover"
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val APP_MANAGED_DOWNLOAD_ID = -1L
|
||||||
|
const val MAX_SIMULTANEOUS_DOWNLOADS = 3
|
||||||
|
const val WATCH_INTERVAL_MS = 1_000L
|
||||||
|
const val STALL_TIMEOUT_MS = 60_000L
|
||||||
|
const val MAX_RETRIES = 5
|
||||||
|
const val MAX_AUTH_RETRIES = 2
|
||||||
|
const val PERSIST_INTERVAL_MS = 2_000L
|
||||||
|
const val MIN_FREE_SPACE_BYTES = 100L * 1024L * 1024L
|
||||||
|
const val UNKNOWN_PART_RESERVATION_BYTES = 100L * 1024L * 1024L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.ExistingWorkPolicy
|
||||||
|
import androidx.work.OneTimeWorkRequestBuilder
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.models.DownloadItem
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** Removes only staging data for terminally failed downloads after their retention window. */
|
||||||
|
object IncompleteDownloadCleanup {
|
||||||
|
private const val tag = "IncompleteDownloadCleanup"
|
||||||
|
private const val RETENTION_MS = 24L * 60L * 60L * 1000L
|
||||||
|
private const val WORK_PREFIX = "incomplete-download-"
|
||||||
|
|
||||||
|
fun schedule(context: Context, item: DownloadItem) {
|
||||||
|
val failedAt = item.terminalFailureAt ?: return
|
||||||
|
if (item.stagingCleanupAt != null) return
|
||||||
|
val delay = (failedAt + RETENTION_MS - System.currentTimeMillis()).coerceAtLeast(0L)
|
||||||
|
val request = OneTimeWorkRequestBuilder<IncompleteDownloadCleanupWorker>()
|
||||||
|
.setInitialDelay(delay, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_PREFIX + item.id, ExistingWorkPolicy.REPLACE, request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel(context: Context, itemId: String) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_PREFIX + itemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cleans staging data retained longer than 24 hours when scheduled work did not run. */
|
||||||
|
@Synchronized
|
||||||
|
fun cleanupExpired(context: Context) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
DeviceManager.dbManager.getDownloadItems()
|
||||||
|
.filter { item -> isEligible(item, now) }
|
||||||
|
.forEach { item ->
|
||||||
|
deleteItem(context, item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isEligible(item: DownloadItem, now: Long): Boolean {
|
||||||
|
val failedAt = item.terminalFailureAt ?: return false
|
||||||
|
if (item.stagingCleanupAt != null) return false
|
||||||
|
if (now - failedAt < RETENTION_MS) return false
|
||||||
|
return item.downloadItemParts.all { part ->
|
||||||
|
part.moved || (part.failed && !part.isMoving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteItem(context: Context, item: DownloadItem) {
|
||||||
|
item.downloadItemParts.forEach { part ->
|
||||||
|
deleteAppOwnedFile(context, File(part.destinationPath))
|
||||||
|
if (!part.moved) {
|
||||||
|
part.bytesDownloaded = 0L
|
||||||
|
part.completed = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
item.stagingCleanupAt = System.currentTimeMillis()
|
||||||
|
DeviceManager.dbManager.saveDownloadItem(item)
|
||||||
|
cancel(context, item.id)
|
||||||
|
AbsLogger.info(tag, "Deleted staging files for terminally failed download item ${item.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteAppOwnedFile(context: Context, file: File) {
|
||||||
|
val path = file.absolutePath
|
||||||
|
val internal = context.filesDir.absolutePath
|
||||||
|
val external = context.getExternalFilesDir(null)?.absolutePath
|
||||||
|
if (path.startsWith(internal) || (external != null && path.startsWith(external))) {
|
||||||
|
if (file.exists() && !file.delete()) AbsLogger.error(tag, "Could not delete expired staging file $path")
|
||||||
|
file.parentFile?.takeIf { it.isDirectory && it.list()?.isEmpty() == true }?.delete()
|
||||||
|
} else {
|
||||||
|
AbsLogger.error(tag, "Refusing to delete non-app-owned path $path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class IncompleteDownloadCleanupWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
||||||
|
override fun doWork(): Result {
|
||||||
|
DbManager.initialize(applicationContext)
|
||||||
|
IncompleteDownloadCleanup.cleanupExpired(applicationContext)
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,203 @@
|
||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicReference
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import okhttp3.Call
|
||||||
|
import okhttp3.Callback
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.Response
|
||||||
|
|
||||||
|
/** Streams a download into an app-owned staging file. */
|
||||||
|
class InternalDownloadManager(
|
||||||
|
private val destinationFile: File,
|
||||||
|
private val expectedSize: Long,
|
||||||
|
private val progressCallback: DownloadItemManager.InternalProgressCallback,
|
||||||
|
private val hasAvailableSpace: () -> Boolean
|
||||||
|
) {
|
||||||
|
private val tag = "InternalDownloadManager"
|
||||||
|
|
||||||
|
interface DownloadHandle {
|
||||||
|
fun cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
private class ActiveDownloadHandle : DownloadHandle {
|
||||||
|
private val cancelled = AtomicBoolean(false)
|
||||||
|
private val activeCall = AtomicReference<Call?>()
|
||||||
|
|
||||||
|
fun setCall(call: Call) {
|
||||||
|
activeCall.set(call)
|
||||||
|
if (cancelled.get()) call.cancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun cancel() {
|
||||||
|
cancelled.set(true)
|
||||||
|
activeCall.get()?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Starts or resumes a download.
|
||||||
|
*
|
||||||
|
* @param url download URL
|
||||||
|
* @param token access token sent in the Authorization header
|
||||||
|
* @return logical handle used to cancel the active request, including a restarted request
|
||||||
|
*/
|
||||||
|
fun download(url: String, token: String): DownloadHandle {
|
||||||
|
destinationFile.parentFile?.mkdirs()
|
||||||
|
val handle = ActiveDownloadHandle()
|
||||||
|
startRequest(url, token, handle, allowRestart = true)
|
||||||
|
return handle
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startRequest(
|
||||||
|
url: String,
|
||||||
|
token: String,
|
||||||
|
handle: ActiveDownloadHandle,
|
||||||
|
allowRestart: Boolean
|
||||||
|
) {
|
||||||
|
var existingBytes = destinationFile.takeIf { it.exists() }?.length() ?: 0L
|
||||||
|
AbsLogger.info(
|
||||||
|
tag,
|
||||||
|
"Starting ${if (existingBytes > 0L) "resumed" else "new"} download for ${destinationFile.name} at byte $existingBytes")
|
||||||
|
val request =
|
||||||
|
Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.addHeader("Accept-Encoding", "identity")
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.apply { if (existingBytes > 0L) header("Range", "bytes=$existingBytes-") }
|
||||||
|
.build()
|
||||||
|
val call = client.newCall(request)
|
||||||
|
handle.setCall(call)
|
||||||
|
call.enqueue(
|
||||||
|
object : Callback {
|
||||||
|
override fun onFailure(call: Call, e: IOException) {
|
||||||
|
AbsLogger.error(tag, "Download request failed for ${destinationFile.name}: ${e.message}")
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResponse(call: Call, response: Response) {
|
||||||
|
response.use {
|
||||||
|
try {
|
||||||
|
if (response.code == 401) {
|
||||||
|
AbsLogger.error(tag, "Download unauthorized (401) for ${destinationFile.name}")
|
||||||
|
progressCallback.onAuthError()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (response.code == 416) {
|
||||||
|
val serverSize =
|
||||||
|
response.header("Content-Range")
|
||||||
|
?.removePrefix("bytes */")
|
||||||
|
?.toLongOrNull()
|
||||||
|
if (serverSize != null) progressCallback.onSizeResolved(serverSize)
|
||||||
|
if (serverSize != null && existingBytes == serverSize) {
|
||||||
|
progressCallback.onProgress(existingBytes, 100L)
|
||||||
|
AbsLogger.info(tag, "Download completed for ${destinationFile.name} ($existingBytes bytes)")
|
||||||
|
progressCallback.onComplete(false)
|
||||||
|
} else if (allowRestart && destinationFile.delete()) {
|
||||||
|
AbsLogger.info(tag, "Restarting stale range from byte zero for ${destinationFile.name}")
|
||||||
|
startRequest(url, token, handle, allowRestart = false)
|
||||||
|
} else {
|
||||||
|
AbsLogger.error(tag, "Could not recover invalid range for ${destinationFile.name} at byte $existingBytes")
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val append =
|
||||||
|
existingBytes > 0L &&
|
||||||
|
response.code == 206 &&
|
||||||
|
hasExpectedRange(response, existingBytes)
|
||||||
|
if (existingBytes > 0L && !append && response.code != 200) {
|
||||||
|
AbsLogger.error(
|
||||||
|
tag,
|
||||||
|
"Invalid resume response ${response.code} for ${destinationFile.name} at byte $existingBytes"
|
||||||
|
)
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!response.isSuccessful || response.body == null) {
|
||||||
|
AbsLogger.error(tag, "Download HTTP failure ${response.code} for ${destinationFile.name}")
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val startingBytes = if (append) existingBytes else 0L
|
||||||
|
val responseLength = response.body!!.contentLength()
|
||||||
|
val serverSize =
|
||||||
|
if (append) contentRangeTotal(response)
|
||||||
|
else responseLength.takeIf { it >= 0L }
|
||||||
|
if (serverSize != null) progressCallback.onSizeResolved(serverSize)
|
||||||
|
val totalLength =
|
||||||
|
serverSize ?: if (expectedSize > 0L) expectedSize else 0L
|
||||||
|
|
||||||
|
FileOutputStream(destinationFile, append).use { output ->
|
||||||
|
response.body!!.byteStream().use { input ->
|
||||||
|
val buffer = ByteArray(CHUNK_SIZE)
|
||||||
|
var totalBytes = startingBytes
|
||||||
|
while (true) {
|
||||||
|
val read = input.read(buffer)
|
||||||
|
if (read < 0) break
|
||||||
|
if (!hasAvailableSpace())
|
||||||
|
throw IOException("Download paused to preserve free storage")
|
||||||
|
output.write(buffer, 0, read)
|
||||||
|
totalBytes += read
|
||||||
|
val progress =
|
||||||
|
if (totalLength > 0L) (totalBytes * 100L) / totalLength else 0L
|
||||||
|
progressCallback.onProgress(totalBytes, progress.coerceAtMost(100L))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val downloadedSize = destinationFile.length()
|
||||||
|
if (serverSize != null && downloadedSize != serverSize) {
|
||||||
|
AbsLogger.error(
|
||||||
|
tag,
|
||||||
|
"Downloaded size for ${destinationFile.name} was $downloadedSize, expected server size $serverSize"
|
||||||
|
)
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
} else {
|
||||||
|
AbsLogger.info(
|
||||||
|
tag,
|
||||||
|
"Download completed for ${destinationFile.name} ($downloadedSize bytes)"
|
||||||
|
)
|
||||||
|
progressCallback.onComplete(false)
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
AbsLogger.error(tag, "Could not write staging file ${destinationFile.name}: ${e.message}")
|
||||||
|
progressCallback.onComplete(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasExpectedRange(response: Response, offset: Long): Boolean {
|
||||||
|
val range = response.header("Content-Range") ?: return false
|
||||||
|
val match = CONTENT_RANGE.matchEntire(range) ?: return false
|
||||||
|
return match.groupValues[1].toLongOrNull() == offset &&
|
||||||
|
match.groupValues[2].toLongOrNull()?.let { it >= offset } == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun contentRangeTotal(response: Response): Long? =
|
||||||
|
CONTENT_RANGE.matchEntire(response.header("Content-Range") ?: "")
|
||||||
|
?.groupValues
|
||||||
|
?.get(3)
|
||||||
|
?.takeUnless { it == "*" }
|
||||||
|
?.toLongOrNull()
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val CHUNK_SIZE = 512 * 1024 // 512 KB
|
||||||
|
val CONTENT_RANGE = Regex("bytes (\\d+)-(\\d+)/(\\d+|\\*)")
|
||||||
|
val client =
|
||||||
|
OkHttpClient.Builder()
|
||||||
|
.connectTimeout(30, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(60, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.security.keystore.KeyGenParameterSpec
|
||||||
|
import android.security.keystore.KeyProperties
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.Log
|
||||||
|
import java.security.KeyStore
|
||||||
|
import javax.crypto.Cipher
|
||||||
|
import javax.crypto.KeyGenerator
|
||||||
|
import javax.crypto.SecretKey
|
||||||
|
import javax.crypto.spec.GCMParameterSpec
|
||||||
|
|
||||||
|
class SecureStorage(private val context: Context) {
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SecureStorage"
|
||||||
|
private const val KEYSTORE_PROVIDER = "AndroidKeyStore"
|
||||||
|
private const val KEY_ALIAS = "AudiobookshelfRefreshTokens"
|
||||||
|
private const val TRANSFORMATION = "AES/GCM/NoPadding"
|
||||||
|
private const val IV_LENGTH = 12
|
||||||
|
private const val TAG_LENGTH = 128
|
||||||
|
}
|
||||||
|
|
||||||
|
private val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply {
|
||||||
|
load(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypts and stores a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun storeRefreshToken(serverConnectionId: String, refreshToken: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
cipher.init(Cipher.ENCRYPT_MODE, key)
|
||||||
|
|
||||||
|
val encryptedBytes = cipher.doFinal(refreshToken.toByteArray(Charsets.UTF_8))
|
||||||
|
val combined = cipher.iv + encryptedBytes
|
||||||
|
|
||||||
|
val encoded = Base64.encodeToString(combined, Base64.DEFAULT)
|
||||||
|
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
sharedPrefs.edit().putString("refresh_token_$serverConnectionId", encoded).apply()
|
||||||
|
|
||||||
|
Log.d(TAG, "Successfully stored encrypted refresh token for server: $serverConnectionId")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to store refresh token for server: $serverConnectionId", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves and decrypts a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun getRefreshToken(serverConnectionId: String): String? {
|
||||||
|
return try {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
val encoded = sharedPrefs.getString("refresh_token_$serverConnectionId", null) ?: return null
|
||||||
|
|
||||||
|
val combined = Base64.decode(encoded, Base64.DEFAULT)
|
||||||
|
val iv = combined.copyOfRange(0, IV_LENGTH)
|
||||||
|
val encryptedBytes = combined.copyOfRange(IV_LENGTH, combined.size)
|
||||||
|
|
||||||
|
val key = getOrCreateKey()
|
||||||
|
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||||
|
val spec = GCMParameterSpec(TAG_LENGTH, iv)
|
||||||
|
cipher.init(Cipher.DECRYPT_MODE, key, spec)
|
||||||
|
|
||||||
|
val decryptedBytes = cipher.doFinal(encryptedBytes)
|
||||||
|
String(decryptedBytes, Charsets.UTF_8)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to retrieve refresh token for server: $serverConnectionId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a refresh token for a specific server connection
|
||||||
|
*/
|
||||||
|
fun removeRefreshToken(serverConnectionId: String): Boolean {
|
||||||
|
return try {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
sharedPrefs.edit().remove("refresh_token_$serverConnectionId").apply()
|
||||||
|
Log.d(TAG, "Successfully removed refresh token for server: $serverConnectionId")
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to remove refresh token for server: $serverConnectionId", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a refresh token exists for a specific server connection
|
||||||
|
*/
|
||||||
|
fun hasRefreshToken(serverConnectionId: String): Boolean {
|
||||||
|
val sharedPrefs = context.getSharedPreferences("SecureStorage", Context.MODE_PRIVATE)
|
||||||
|
return sharedPrefs.contains("refresh_token_$serverConnectionId")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreateKey(): SecretKey {
|
||||||
|
return if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||||
|
keyStore.getKey(KEY_ALIAS, null) as SecretKey
|
||||||
|
} else {
|
||||||
|
createKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createKey(): SecretKey {
|
||||||
|
val keyGenerator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER)
|
||||||
|
val keyGenSpec = KeyGenParameterSpec.Builder(
|
||||||
|
KEY_ALIAS,
|
||||||
|
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
|
||||||
|
)
|
||||||
|
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
|
||||||
|
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
|
||||||
|
.setUserAuthenticationRequired(false)
|
||||||
|
.setRandomizedEncryptionRequired(true)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
keyGenerator.init(keyGenSpec)
|
||||||
|
return keyGenerator.generateKey()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,544 @@
|
||||||
|
package com.audiobookshelf.app.managers
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.media.MediaPlayer
|
||||||
|
import android.os.*
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
import com.audiobookshelf.app.player.SLEEP_TIMER_WAKE_UP_EXPIRATION
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.concurrent.schedule
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
const val SLEEP_TIMER_CHIME_SOUND_VOLUME = 0.7f
|
||||||
|
|
||||||
|
class SleepTimerManager
|
||||||
|
constructor(private val playerNotificationService: PlayerNotificationService) {
|
||||||
|
private val tag = "SleepTimerManager"
|
||||||
|
|
||||||
|
private var sleepTimerTask: TimerTask? = null
|
||||||
|
private var sleepTimerRunning: Boolean = false
|
||||||
|
private var sleepTimerEndTime: Long = 0L
|
||||||
|
private var sleepTimerLength: Long = 0L
|
||||||
|
private var sleepTimerElapsed: Long = 0L
|
||||||
|
private var sleepTimerFinishedAt: Long = 0L
|
||||||
|
private var isAutoSleepTimer: Boolean = false // When timer was auto-set
|
||||||
|
private var autoTimerDisabled: Boolean = false // Disable until out of auto timer period
|
||||||
|
private var sleepTimerSessionId: String = ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the current time from the player notification service.
|
||||||
|
* @return Long - the current time in milliseconds.
|
||||||
|
*/
|
||||||
|
private fun getCurrentTime(): Long {
|
||||||
|
return playerNotificationService.getCurrentTime()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the duration of the current playback.
|
||||||
|
* @return Long - the duration in milliseconds.
|
||||||
|
*/
|
||||||
|
private fun getDuration(): Long {
|
||||||
|
return playerNotificationService.getDuration()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the player is currently playing.
|
||||||
|
* @return Boolean - true if the player is playing, false otherwise.
|
||||||
|
*/
|
||||||
|
private fun getIsPlaying(): Boolean {
|
||||||
|
return playerNotificationService.currentPlayer.isPlaying
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the playback speed of the player.
|
||||||
|
* @return Float - the playback speed.
|
||||||
|
*/
|
||||||
|
private fun getPlaybackSpeed(): Float {
|
||||||
|
return playerNotificationService.currentPlayer.playbackParameters.speed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the volume of the player.
|
||||||
|
* @param volume Float - the volume level to set.
|
||||||
|
*/
|
||||||
|
private fun setVolume(volume: Float) {
|
||||||
|
playerNotificationService.currentPlayer.volume = volume
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pauses the player. */
|
||||||
|
private fun pause() {
|
||||||
|
playerNotificationService.currentPlayer.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays the player. */
|
||||||
|
private fun play() {
|
||||||
|
playerNotificationService.currentPlayer.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the remaining time of the sleep timer in seconds.
|
||||||
|
* @param speed Float - the playback speed of the player, default value is 1.
|
||||||
|
* @return Int - the remaining time in seconds.
|
||||||
|
*/
|
||||||
|
private fun getSleepTimerTimeRemainingSeconds(speed: Float = 1f): Int {
|
||||||
|
if (sleepTimerEndTime == 0L && sleepTimerLength > 0) { // For regular timer
|
||||||
|
return ((sleepTimerLength - sleepTimerElapsed) / 1000).toDouble().roundToInt()
|
||||||
|
}
|
||||||
|
// For chapter end timer
|
||||||
|
if (sleepTimerEndTime <= 0) return 0
|
||||||
|
return (((sleepTimerEndTime - getCurrentTime()) / 1000).toDouble() / speed).roundToInt()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the sleep timer.
|
||||||
|
* @param time Long - the time to set the sleep timer for. When 0L, use end of chapter/track time.
|
||||||
|
* @return Boolean - true if the sleep timer was set successfully, false otherwise.
|
||||||
|
*/
|
||||||
|
private fun setSleepTimer(time: Long): Boolean {
|
||||||
|
Log.d(tag, "Setting Sleep Timer for $time")
|
||||||
|
sleepTimerTask?.cancel()
|
||||||
|
sleepTimerRunning = true
|
||||||
|
sleepTimerFinishedAt = 0L
|
||||||
|
sleepTimerElapsed = 0L
|
||||||
|
setVolume(1f)
|
||||||
|
|
||||||
|
if (time == 0L) {
|
||||||
|
// Get the current chapter time and set the sleep timer to the end of the chapter
|
||||||
|
val chapterEndTime = this.getChapterEndTime()
|
||||||
|
|
||||||
|
if (chapterEndTime == null) {
|
||||||
|
Log.e(tag, "Setting sleep timer to end of chapter/track but there is no current session")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val currentTime = getCurrentTime()
|
||||||
|
if (currentTime > chapterEndTime) {
|
||||||
|
Log.d(tag, "Invalid sleep timer - time is already past chapter time $chapterEndTime")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
sleepTimerEndTime = chapterEndTime
|
||||||
|
|
||||||
|
if (sleepTimerEndTime > getDuration()) {
|
||||||
|
sleepTimerEndTime = getDuration()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
sleepTimerEndTime = 0L
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set sleep timer length. Will be 0L if using chapter end time
|
||||||
|
sleepTimerLength = time
|
||||||
|
|
||||||
|
// Register shake sensor
|
||||||
|
playerNotificationService.registerSensor()
|
||||||
|
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||||
|
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||||
|
isAutoSleepTimer
|
||||||
|
)
|
||||||
|
|
||||||
|
sleepTimerTask =
|
||||||
|
Timer("SleepTimer", false).schedule(0L, 1000L) {
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
if (getIsPlaying()) {
|
||||||
|
sleepTimerElapsed += 1000L
|
||||||
|
|
||||||
|
val sleepTimeSecondsRemaining =
|
||||||
|
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"Timer Elapsed $sleepTimerElapsed | Sleep TIMER time remaining $sleepTimeSecondsRemaining s"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (sleepTimeSecondsRemaining > 0) {
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||||
|
sleepTimeSecondsRemaining,
|
||||||
|
isAutoSleepTimer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sleepTimeSecondsRemaining == 30 && sleepTimerElapsed > 1 && DeviceManager.deviceData.deviceSettings?.enableSleepTimerAlmostDoneChime == true) {
|
||||||
|
playChimeSound()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sleepTimeSecondsRemaining <= 0) {
|
||||||
|
Log.d(tag, "Sleep Timer Pausing Player on Chapter")
|
||||||
|
pause()
|
||||||
|
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerEnded(
|
||||||
|
getCurrentTime()
|
||||||
|
)
|
||||||
|
clearSleepTimer()
|
||||||
|
sleepTimerFinishedAt = System.currentTimeMillis()
|
||||||
|
} else if (sleepTimeSecondsRemaining <= 60 &&
|
||||||
|
DeviceManager.deviceData
|
||||||
|
.deviceSettings
|
||||||
|
?.disableSleepTimerFadeOut != true
|
||||||
|
) {
|
||||||
|
// Start fading out audio down to 10% volume
|
||||||
|
val percentToReduce = 1 - (sleepTimeSecondsRemaining / 60F)
|
||||||
|
val volume = 1f - (percentToReduce * 0.9f)
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"SLEEP VOLUME FADE $volume | ${sleepTimeSecondsRemaining}s remaining"
|
||||||
|
)
|
||||||
|
setVolume(volume)
|
||||||
|
} else {
|
||||||
|
setVolume(1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a manual sleep timer.
|
||||||
|
* @param playbackSessionId String - the playback session ID.
|
||||||
|
* @param time Long - the time to set the sleep timer for.
|
||||||
|
* @param isChapterTime Boolean - true if the time is for the end of a chapter, false otherwise.
|
||||||
|
* @return Boolean - true if the sleep timer was set successfully, false otherwise.
|
||||||
|
*/
|
||||||
|
fun setManualSleepTimer(playbackSessionId: String, time: Long, isChapterTime: Boolean): Boolean {
|
||||||
|
sleepTimerSessionId = playbackSessionId
|
||||||
|
isAutoSleepTimer = false
|
||||||
|
if (isChapterTime) {
|
||||||
|
Log.d(tag, "Setting manual sleep timer for end of chapter")
|
||||||
|
return setSleepTimer(0L)
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "Setting manual sleep timer for $time")
|
||||||
|
return setSleepTimer(time)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the sleep timer. */
|
||||||
|
private fun clearSleepTimer() {
|
||||||
|
sleepTimerTask?.cancel()
|
||||||
|
sleepTimerTask = null
|
||||||
|
sleepTimerEndTime = 0
|
||||||
|
sleepTimerRunning = false
|
||||||
|
playerNotificationService.unregisterSensor()
|
||||||
|
|
||||||
|
setVolume(1f)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the sleep timer end time.
|
||||||
|
* @return Long - the sleep timer end time in milliseconds.
|
||||||
|
*/
|
||||||
|
fun getSleepTimerTime(): Long {
|
||||||
|
return sleepTimerEndTime
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cancels the sleep timer. */
|
||||||
|
fun cancelSleepTimer() {
|
||||||
|
Log.d(tag, "Canceling Sleep Timer")
|
||||||
|
|
||||||
|
if (isAutoSleepTimer) {
|
||||||
|
Log.i(tag, "Disabling auto sleep timer for this time period")
|
||||||
|
autoTimerDisabled = true
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSleepTimer()
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Provides vibration feedback when resetting the sleep timer. */
|
||||||
|
private fun vibrateFeedback() {
|
||||||
|
if (DeviceManager.deviceData.deviceSettings?.disableSleepTimerResetFeedback == true) return
|
||||||
|
|
||||||
|
val context = playerNotificationService.getContext()
|
||||||
|
val vibrator: Vibrator
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val vibratorManager =
|
||||||
|
context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||||
|
vibrator = vibratorManager.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
vibrator = context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
}
|
||||||
|
|
||||||
|
vibrator.let {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
val vibrationEffect = VibrationEffect.createWaveform(longArrayOf(0, 150, 150, 150), -1)
|
||||||
|
it.vibrate(vibrationEffect)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION") it.vibrate(10)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plays chime sound */
|
||||||
|
private fun playChimeSound() {
|
||||||
|
AbsLogger.info(tag, "playChimeSound: Playing sleep timer chime sound")
|
||||||
|
val ctx = playerNotificationService.getContext()
|
||||||
|
val mediaPlayer = MediaPlayer.create(ctx, R.raw.bell)
|
||||||
|
mediaPlayer.setVolume(SLEEP_TIMER_CHIME_SOUND_VOLUME, SLEEP_TIMER_CHIME_SOUND_VOLUME)
|
||||||
|
mediaPlayer.start()
|
||||||
|
mediaPlayer.setOnCompletionListener {
|
||||||
|
mediaPlayer.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the chapter end time for use in End of Chapter timers. If less than 10 seconds remain in
|
||||||
|
* the chapter, then use the next chapter.
|
||||||
|
* @return Long? - the chapter end time in milliseconds, or null if there is no current session.
|
||||||
|
*/
|
||||||
|
private fun getChapterEndTime(): Long? {
|
||||||
|
val currentChapterEndTimeMs = playerNotificationService.getEndTimeOfChapterOrTrack()
|
||||||
|
if (currentChapterEndTimeMs == null) {
|
||||||
|
Log.e(tag, "Getting chapter sleep timer end of chapter/track but there is no current session")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
val timeLeftInChapter = currentChapterEndTimeMs - getCurrentTime()
|
||||||
|
// If less than 10 seconds remain in the chapter, set the timer to the next chapter or track
|
||||||
|
// This handles the auto-rewind from not playing media for a little bit to select the next
|
||||||
|
// chapter
|
||||||
|
return if (timeLeftInChapter < 10000L) {
|
||||||
|
Log.i(tag, "Getting chapter sleep timer time and current chapter has less than 10s remaining")
|
||||||
|
val nextChapterEndTimeMs = playerNotificationService.getEndTimeOfNextChapterOrTrack()
|
||||||
|
if (nextChapterEndTimeMs == null || currentChapterEndTimeMs == nextChapterEndTimeMs) {
|
||||||
|
Log.e(
|
||||||
|
tag,
|
||||||
|
"Invalid next chapter time. No current session or equal to current chapter. $nextChapterEndTimeMs"
|
||||||
|
)
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
nextChapterEndTimeMs
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentChapterEndTimeMs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rewind auto sleep timer if setting enabled. To ensure the first rewind of the time period does
|
||||||
|
* not take place, make sure to set `isAutoSleepTimer` after calling this function.
|
||||||
|
*/
|
||||||
|
private fun tryRewindAutoSleepTimer() {
|
||||||
|
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||||
|
if (isAutoSleepTimer && deviceSettings.autoSleepTimerAutoRewind) {
|
||||||
|
Log.i(
|
||||||
|
tag,
|
||||||
|
"Auto sleep timer auto rewind seeking back ${deviceSettings.autoSleepTimerAutoRewindTime}ms"
|
||||||
|
)
|
||||||
|
playerNotificationService.seekBackward(deviceSettings.autoSleepTimerAutoRewindTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks if the sleep timer should be reset. */
|
||||||
|
private fun checkShouldResetSleepTimer() {
|
||||||
|
if (sleepTimerRunning) {
|
||||||
|
// Reset the sleep timer if it has been running for at least 3 seconds or it is an end of
|
||||||
|
// chapter/track timer
|
||||||
|
if (sleepTimerLength == 0L || sleepTimerElapsed > 3000L) {
|
||||||
|
Log.d(tag, "Resetting running sleep timer")
|
||||||
|
vibrateFeedback()
|
||||||
|
setSleepTimer(sleepTimerLength)
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
|
||||||
|
if (sleepTimerFinishedAt <= 0L) return
|
||||||
|
|
||||||
|
val 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// If timer was cleared by going negative on time, clear the sleep timer length so pressing
|
||||||
|
// play allows playback to continue without the sleep timer continuously setting for 1 second.
|
||||||
|
if (sleepTimerLength == 1000L) {
|
||||||
|
Log.d(tag, "Sleep timer cleared by manually subtracting time, clearing sleep timer")
|
||||||
|
sleepTimerFinishedAt = 0L
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Automatically rewind in the book if settings are enabled
|
||||||
|
tryRewindAutoSleepTimer()
|
||||||
|
|
||||||
|
// Set sleep timer
|
||||||
|
Log.d(tag, "Resetting stopped sleep timer")
|
||||||
|
vibrateFeedback()
|
||||||
|
setSleepTimer(sleepTimerLength)
|
||||||
|
play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the shake event to reset the sleep timer. Shaking to reset only works during the 2
|
||||||
|
* minute grace period after the timer ends or while media is playing.
|
||||||
|
*/
|
||||||
|
fun handleShake() {
|
||||||
|
if ((sleepTimerRunning && getIsPlaying()) || sleepTimerFinishedAt > 0L) {
|
||||||
|
if (DeviceManager.deviceData.deviceSettings?.disableShakeToResetSleepTimer == true) {
|
||||||
|
Log.d(tag, "Shake to reset sleep timer is disabled")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
checkShouldResetSleepTimer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increases the sleep timer time.
|
||||||
|
* @param time Long - the time to increase the sleep timer by.
|
||||||
|
*/
|
||||||
|
fun increaseSleepTime(time: Long) {
|
||||||
|
Log.d(tag, "Increase Sleep time $time")
|
||||||
|
if (!sleepTimerRunning) return
|
||||||
|
|
||||||
|
// Increase the sleep timer time (if using fixed length) or end time (if using chapter end time)
|
||||||
|
// and ensure it doesn't go over the duration of the current playback item
|
||||||
|
if (sleepTimerEndTime == 0L) {
|
||||||
|
// Fixed length
|
||||||
|
sleepTimerLength += time
|
||||||
|
sleepTimerLength = minOf(sleepTimerLength, getDuration() - getCurrentTime())
|
||||||
|
} else {
|
||||||
|
// Chapter end time
|
||||||
|
sleepTimerEndTime =
|
||||||
|
minOf(sleepTimerEndTime + (time * getPlaybackSpeed()).roundToInt(), getDuration())
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(1F)
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||||
|
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||||
|
isAutoSleepTimer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decreases the sleep timer time.
|
||||||
|
* @param time Long - the time to decrease the sleep timer by.
|
||||||
|
*/
|
||||||
|
fun decreaseSleepTime(time: Long) {
|
||||||
|
Log.d(tag, "Decrease Sleep time $time")
|
||||||
|
if (!sleepTimerRunning) return
|
||||||
|
|
||||||
|
// Decrease the sleep timer time (if using fixed length) or end time (if using chapter end time)
|
||||||
|
// and ensure it doesn't go below 1 second
|
||||||
|
if (sleepTimerEndTime == 0L) {
|
||||||
|
// Fixed length
|
||||||
|
sleepTimerLength = maxOf(sleepTimerLength - time, 1000L)
|
||||||
|
} else {
|
||||||
|
// Chapter end time
|
||||||
|
sleepTimerEndTime =
|
||||||
|
maxOf(
|
||||||
|
sleepTimerEndTime - (time * getPlaybackSpeed()).roundToInt(),
|
||||||
|
getCurrentTime() + 1000
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
setVolume(1F)
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(
|
||||||
|
getSleepTimerTimeRemainingSeconds(getPlaybackSpeed()),
|
||||||
|
isAutoSleepTimer
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checks whether the auto sleep timer should be set, and set up auto sleep timer if so. */
|
||||||
|
fun checkAutoSleepTimer() {
|
||||||
|
if (sleepTimerRunning) { // Sleep timer already running
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DeviceManager.deviceData.deviceSettings?.let { deviceSettings ->
|
||||||
|
if (!deviceSettings.autoSleepTimer) return // Check auto sleep timer is enabled
|
||||||
|
|
||||||
|
val startCalendar = Calendar.getInstance()
|
||||||
|
startCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerStartHour)
|
||||||
|
startCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerStartMinute)
|
||||||
|
val endCalendar = Calendar.getInstance()
|
||||||
|
endCalendar.set(Calendar.HOUR_OF_DAY, deviceSettings.autoSleepTimerEndHour)
|
||||||
|
endCalendar.set(Calendar.MINUTE, deviceSettings.autoSleepTimerEndMinute)
|
||||||
|
|
||||||
|
val currentCalendar = Calendar.getInstance()
|
||||||
|
|
||||||
|
// In cases where end time is before start time then we shift the time window forward or
|
||||||
|
// backward based on the current time.
|
||||||
|
// e.g. start time 22:00 and end time 06:00.
|
||||||
|
// If current time is less than start time (e.g. 00:30) then start time will be the
|
||||||
|
// previous day.
|
||||||
|
// If current time is greater than start time (e.g. 23:00) then end time will be the
|
||||||
|
// next day.
|
||||||
|
if (endCalendar.before(startCalendar)) {
|
||||||
|
if (currentCalendar.before(startCalendar)) { // Shift start back a day
|
||||||
|
startCalendar.add(Calendar.DAY_OF_MONTH, -1)
|
||||||
|
} else { // Shift end forward a day
|
||||||
|
endCalendar.add(Calendar.DAY_OF_MONTH, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val isDuringAutoTime =
|
||||||
|
currentCalendar.after(startCalendar) && currentCalendar.before(endCalendar)
|
||||||
|
|
||||||
|
// Determine whether to set the auto sleep timer or not
|
||||||
|
if (autoTimerDisabled) {
|
||||||
|
if (!isDuringAutoTime) {
|
||||||
|
// Check if sleep timer was disabled during the previous period and enable again
|
||||||
|
Log.i(tag, "Leaving disabled auto sleep time period, enabling for next time period")
|
||||||
|
autoTimerDisabled = false
|
||||||
|
} else {
|
||||||
|
// Auto time is disabled, do not set sleep timer
|
||||||
|
Log.i(tag, "Auto sleep timer is disabled for this time period")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (isDuringAutoTime) {
|
||||||
|
// Start an auto sleep timer
|
||||||
|
val currentHour = currentCalendar.get(Calendar.HOUR_OF_DAY)
|
||||||
|
val currentMin = currentCalendar.get(Calendar.MINUTE)
|
||||||
|
Log.i(tag, "Starting auto sleep timer at $currentHour:$currentMin")
|
||||||
|
|
||||||
|
// Automatically rewind in the book if settings is enabled
|
||||||
|
tryRewindAutoSleepTimer()
|
||||||
|
|
||||||
|
// Set `isAutoSleepTimer` to true to indicate that the timer was set automatically
|
||||||
|
// and to not cause the timer to rewind
|
||||||
|
isAutoSleepTimer = true
|
||||||
|
setSleepTimer(deviceSettings.sleepTimerLength)
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "Not in auto sleep time period")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the media play event and checks if the sleep timer should be reset or set.
|
||||||
|
* @param playbackSessionId String - the playback session ID.
|
||||||
|
*/
|
||||||
|
fun handleMediaPlayEvent(playbackSessionId: String) {
|
||||||
|
// Check if the playback session has changed
|
||||||
|
// If it hasn't changed OR the sleep timer is running then check reset the timer
|
||||||
|
// e.g. You set a manual sleep timer for 10 mins, then decide to change books, the sleep timer
|
||||||
|
// will stay on and reset to 10 mins
|
||||||
|
if (sleepTimerSessionId == playbackSessionId || sleepTimerRunning) {
|
||||||
|
checkShouldResetSleepTimer()
|
||||||
|
} else {}
|
||||||
|
sleepTimerSessionId = playbackSessionId
|
||||||
|
|
||||||
|
checkAutoSleepTimer()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends the current sleep timer state to the client.
|
||||||
|
* Called when app resumes from background to sync UI state.
|
||||||
|
*/
|
||||||
|
fun sendCurrentSleepTimerState() {
|
||||||
|
if (sleepTimerRunning) {
|
||||||
|
val timeRemaining = getSleepTimerTimeRemainingSeconds(getPlaybackSpeed())
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(timeRemaining, isAutoSleepTimer)
|
||||||
|
} else {
|
||||||
|
// No timer running - send 0 to clear any stale UI state
|
||||||
|
playerNotificationService.clientEventEmitter?.onSleepTimerSet(0, false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,132 @@
|
||||||
|
package com.audiobookshelf.app.media
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.data.*
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
|
||||||
|
object MediaEventManager {
|
||||||
|
const val tag = "MediaEventManager"
|
||||||
|
|
||||||
|
var clientEventEmitter: PlayerNotificationService.ClientEventEmitter? = null
|
||||||
|
|
||||||
|
fun playEvent(playbackSession: PlaybackSession) {
|
||||||
|
Log.i(tag, "Play Event for media \"${playbackSession.displayTitle}\"")
|
||||||
|
addPlaybackEvent("Play", playbackSession, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pauseEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||||
|
Log.i(tag, "Pause Event for media \"${playbackSession.displayTitle}\"")
|
||||||
|
addPlaybackEvent("Pause", playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||||
|
Log.i(tag, "Stop Event for media \"${playbackSession.displayTitle}\"")
|
||||||
|
addPlaybackEvent("Stop", playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||||
|
Log.i(tag, "Save Event for media \"${playbackSession.displayTitle}\"")
|
||||||
|
addPlaybackEvent("Save", playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishedEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||||
|
Log.i(tag, "Finished Event for media \"${playbackSession.displayTitle}\"")
|
||||||
|
addPlaybackEvent("Finished", playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seekEvent(playbackSession: PlaybackSession, syncResult: SyncResult?) {
|
||||||
|
Log.i(
|
||||||
|
tag,
|
||||||
|
"Seek Event for media \"${playbackSession.displayTitle}\", currentTime=${playbackSession.currentTime}"
|
||||||
|
)
|
||||||
|
addPlaybackEvent("Seek", playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncEvent(mediaProgress: MediaProgressWrapper, description: String) {
|
||||||
|
Log.i(
|
||||||
|
tag,
|
||||||
|
"Sync Event for media item id \"${mediaProgress.mediaItemId}\", currentTime=${mediaProgress.currentTime}"
|
||||||
|
)
|
||||||
|
addSyncEvent("Sync", mediaProgress, description)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addSyncEvent(
|
||||||
|
eventName: String,
|
||||||
|
mediaProgress: MediaProgressWrapper,
|
||||||
|
description: String
|
||||||
|
) {
|
||||||
|
val mediaItemHistory = getMediaItemHistoryMediaItem(mediaProgress.mediaItemId)
|
||||||
|
if (mediaItemHistory == null) {
|
||||||
|
Log.w(
|
||||||
|
tag,
|
||||||
|
"addSyncEvent: Media Item History not created yet for media item id ${mediaProgress.mediaItemId}"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaItemEvent =
|
||||||
|
MediaItemEvent(
|
||||||
|
name = eventName,
|
||||||
|
type = "Sync",
|
||||||
|
description = description,
|
||||||
|
currentTime = mediaProgress.currentTime,
|
||||||
|
serverSyncAttempted = false,
|
||||||
|
serverSyncSuccess = null,
|
||||||
|
serverSyncMessage = null,
|
||||||
|
timestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
mediaItemHistory.events.add(mediaItemEvent)
|
||||||
|
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||||
|
|
||||||
|
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun addPlaybackEvent(
|
||||||
|
eventName: String,
|
||||||
|
playbackSession: PlaybackSession,
|
||||||
|
syncResult: SyncResult?
|
||||||
|
) {
|
||||||
|
val mediaItemHistory =
|
||||||
|
getMediaItemHistoryMediaItem(playbackSession.mediaItemId)
|
||||||
|
?: createMediaItemHistoryForSession(playbackSession)
|
||||||
|
|
||||||
|
val mediaItemEvent =
|
||||||
|
MediaItemEvent(
|
||||||
|
name = eventName,
|
||||||
|
type = "Playback",
|
||||||
|
description = "",
|
||||||
|
currentTime = playbackSession.currentTime,
|
||||||
|
serverSyncAttempted = syncResult?.serverSyncAttempted ?: false,
|
||||||
|
serverSyncSuccess = syncResult?.serverSyncSuccess,
|
||||||
|
serverSyncMessage = syncResult?.serverSyncMessage,
|
||||||
|
timestamp = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
mediaItemHistory.events.add(mediaItemEvent)
|
||||||
|
DeviceManager.dbManager.saveMediaItemHistory(mediaItemHistory)
|
||||||
|
|
||||||
|
clientEventEmitter?.onMediaItemHistoryUpdated(mediaItemHistory)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMediaItemHistoryMediaItem(mediaItemId: String): MediaItemHistory? {
|
||||||
|
return DeviceManager.dbManager.getMediaItemHistory(mediaItemId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createMediaItemHistoryForSession(playbackSession: PlaybackSession): MediaItemHistory {
|
||||||
|
Log.i(tag, "Creating new media item history for media \"${playbackSession.displayTitle}\"")
|
||||||
|
val libraryItemId = playbackSession.libraryItemId ?: ""
|
||||||
|
val episodeId: String? = playbackSession.episodeId
|
||||||
|
return MediaItemHistory(
|
||||||
|
id = playbackSession.mediaItemId,
|
||||||
|
mediaDisplayTitle = playbackSession.displayTitle ?: "Unset",
|
||||||
|
libraryItemId,
|
||||||
|
episodeId,
|
||||||
|
false, // local-only items are not supported
|
||||||
|
playbackSession.serverConnectionConfigId,
|
||||||
|
playbackSession.serverAddress,
|
||||||
|
playbackSession.userId,
|
||||||
|
createdAt = System.currentTimeMillis(),
|
||||||
|
events = mutableListOf()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,360 @@
|
||||||
|
package com.audiobookshelf.app.media
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.data.LocalMediaProgress
|
||||||
|
import com.audiobookshelf.app.data.MediaProgress
|
||||||
|
import com.audiobookshelf.app.data.PlaybackSession
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.audiobookshelf.app.player.PlayerNotificationService
|
||||||
|
import com.audiobookshelf.app.plugins.AbsLogger
|
||||||
|
import com.audiobookshelf.app.server.ApiHandler
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
|
data class MediaProgressSyncData(
|
||||||
|
var timeListened: Long, // seconds
|
||||||
|
var duration: Double, // seconds
|
||||||
|
var currentTime: Double // seconds
|
||||||
|
)
|
||||||
|
|
||||||
|
data class SyncResult(
|
||||||
|
var serverSyncAttempted: Boolean,
|
||||||
|
var serverSyncSuccess: Boolean?,
|
||||||
|
var serverSyncMessage: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
class MediaProgressSyncer(
|
||||||
|
val playerNotificationService: PlayerNotificationService,
|
||||||
|
private val apiHandler: ApiHandler
|
||||||
|
) {
|
||||||
|
private val tag = "MediaProgressSync"
|
||||||
|
private val METERED_CONNECTION_SYNC_INTERVAL = 60000
|
||||||
|
|
||||||
|
private var listeningTimerTask: TimerTask? = null
|
||||||
|
var listeningTimerRunning: Boolean = false
|
||||||
|
|
||||||
|
private var lastSyncTime: Long = 0
|
||||||
|
private var failedSyncs: Int = 0
|
||||||
|
|
||||||
|
var currentPlaybackSession: PlaybackSession? = null // copy of pb session currently syncing
|
||||||
|
var currentLocalMediaProgress: LocalMediaProgress? = null
|
||||||
|
|
||||||
|
private val currentDisplayTitle
|
||||||
|
get() = currentPlaybackSession?.displayTitle ?: "Unset"
|
||||||
|
val currentIsLocal
|
||||||
|
get() = currentPlaybackSession?.isLocal == true
|
||||||
|
val currentSessionId
|
||||||
|
get() = currentPlaybackSession?.id ?: ""
|
||||||
|
private val currentPlaybackDuration
|
||||||
|
get() = currentPlaybackSession?.duration ?: 0.0
|
||||||
|
|
||||||
|
fun start(playbackSession: PlaybackSession) {
|
||||||
|
if (listeningTimerRunning) {
|
||||||
|
Log.d(tag, "start: Timer already running for $currentDisplayTitle")
|
||||||
|
if (playbackSession.id != currentSessionId) {
|
||||||
|
Log.d(tag, "Playback session changed, reset timer")
|
||||||
|
currentLocalMediaProgress = null
|
||||||
|
listeningTimerTask?.cancel()
|
||||||
|
lastSyncTime = 0L
|
||||||
|
Log.d(tag, "start: Set last sync time 0 $lastSyncTime")
|
||||||
|
failedSyncs = 0
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if (playbackSession.id != currentSessionId) {
|
||||||
|
currentLocalMediaProgress = null
|
||||||
|
}
|
||||||
|
|
||||||
|
listeningTimerRunning = true
|
||||||
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
currentPlaybackSession = playbackSession.clone()
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"start: init last sync time $lastSyncTime with playback session id=${currentPlaybackSession?.id}"
|
||||||
|
)
|
||||||
|
|
||||||
|
listeningTimerTask =
|
||||||
|
Timer("ListeningTimer", false).schedule(15000L, 15000L) {
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
if (playerNotificationService.currentPlayer.isPlaying) {
|
||||||
|
// Set auto sleep timer if enabled and within start/end time
|
||||||
|
playerNotificationService.sleepTimerManager.checkAutoSleepTimer()
|
||||||
|
|
||||||
|
// Only sync with server on unmetered connection every 15s OR sync with server if
|
||||||
|
// last sync time is >= 60s
|
||||||
|
val shouldSyncServer =
|
||||||
|
PlayerNotificationService.isUnmeteredNetwork ||
|
||||||
|
System.currentTimeMillis() - lastSyncTime >=
|
||||||
|
METERED_CONNECTION_SYNC_INTERVAL
|
||||||
|
|
||||||
|
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
|
if (currentTime > 0) {
|
||||||
|
sync(shouldSyncServer, currentTime) { syncResult ->
|
||||||
|
Log.d(tag, "Sync complete")
|
||||||
|
|
||||||
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
|
MediaEventManager.saveEvent(playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun play(playbackSession: PlaybackSession) {
|
||||||
|
Log.d(tag, "play ${playbackSession.displayTitle}")
|
||||||
|
MediaEventManager.playEvent(playbackSession)
|
||||||
|
|
||||||
|
start(playbackSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(shouldSync: Boolean? = true, cb: () -> Unit) {
|
||||||
|
if (!listeningTimerRunning) {
|
||||||
|
reset()
|
||||||
|
return cb()
|
||||||
|
}
|
||||||
|
|
||||||
|
listeningTimerTask?.cancel()
|
||||||
|
listeningTimerTask = null
|
||||||
|
listeningTimerRunning = false
|
||||||
|
Log.d(tag, "stop: Stopping listening for $currentDisplayTitle")
|
||||||
|
|
||||||
|
val currentTime =
|
||||||
|
if (shouldSync == true) playerNotificationService.getCurrentTimeSeconds() else 0.0
|
||||||
|
if (currentTime > 0) { // Current time should always be > 0 on stop
|
||||||
|
sync(true, currentTime) { syncResult ->
|
||||||
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
|
MediaEventManager.stopEvent(playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
reset()
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
|
MediaEventManager.stopEvent(playbackSession, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
reset()
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pause(cb: () -> Unit) {
|
||||||
|
if (!listeningTimerRunning) return
|
||||||
|
|
||||||
|
listeningTimerTask?.cancel()
|
||||||
|
listeningTimerTask = null
|
||||||
|
listeningTimerRunning = false
|
||||||
|
Log.d(tag, "pause: Pausing progress syncer for $currentDisplayTitle")
|
||||||
|
Log.d(tag, "pause: Last sync time $lastSyncTime")
|
||||||
|
|
||||||
|
val currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
|
if (currentTime > 0) { // Current time should always be > 0 on pause
|
||||||
|
sync(true, currentTime) { syncResult ->
|
||||||
|
lastSyncTime = 0L
|
||||||
|
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime")
|
||||||
|
failedSyncs = 0
|
||||||
|
|
||||||
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
|
MediaEventManager.pauseEvent(playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastSyncTime = 0L
|
||||||
|
Log.d(tag, "pause: Set last sync time 0 $lastSyncTime (current time < 0)")
|
||||||
|
failedSyncs = 0
|
||||||
|
|
||||||
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
|
MediaEventManager.pauseEvent(playbackSession, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finished(cb: () -> Unit) {
|
||||||
|
if (!listeningTimerRunning) return
|
||||||
|
|
||||||
|
listeningTimerTask?.cancel()
|
||||||
|
listeningTimerTask = null
|
||||||
|
listeningTimerRunning = false
|
||||||
|
Log.d(tag, "finished: Stopping listening for $currentDisplayTitle")
|
||||||
|
|
||||||
|
sync(true, currentPlaybackSession?.duration ?: 0.0) { syncResult ->
|
||||||
|
reset()
|
||||||
|
|
||||||
|
currentPlaybackSession?.let { playbackSession ->
|
||||||
|
MediaEventManager.finishedEvent(playbackSession, syncResult)
|
||||||
|
}
|
||||||
|
|
||||||
|
cb()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun seek() {
|
||||||
|
currentPlaybackSession?.currentTime = playerNotificationService.getCurrentTimeSeconds()
|
||||||
|
Log.d(tag, "seek: $currentDisplayTitle, currentTime=${currentPlaybackSession?.currentTime}")
|
||||||
|
|
||||||
|
if (currentPlaybackSession == null) {
|
||||||
|
Log.e(tag, "seek: Playback session not set")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaEventManager.seekEvent(currentPlaybackSession!!, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Currently unused
|
||||||
|
fun syncFromServerProgress(mediaProgress: MediaProgress) {
|
||||||
|
currentPlaybackSession?.let {
|
||||||
|
it.updatedAt = mediaProgress.lastUpdate
|
||||||
|
it.currentTime = mediaProgress.currentTime
|
||||||
|
|
||||||
|
MediaEventManager.syncEvent(
|
||||||
|
mediaProgress,
|
||||||
|
"Received from server get media progress request while playback session open"
|
||||||
|
)
|
||||||
|
saveLocalProgress(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sync(shouldSyncServer: Boolean, currentTime: Double, cb: (SyncResult?) -> Unit) {
|
||||||
|
if (lastSyncTime <= 0) {
|
||||||
|
Log.e(tag, "Last sync time is not set $lastSyncTime")
|
||||||
|
return cb(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val diffSinceLastSync = System.currentTimeMillis() - lastSyncTime
|
||||||
|
if (diffSinceLastSync < 1000L) {
|
||||||
|
return cb(null)
|
||||||
|
}
|
||||||
|
val listeningTimeToAdd = diffSinceLastSync / 1000L
|
||||||
|
|
||||||
|
val syncData = MediaProgressSyncData(listeningTimeToAdd, currentPlaybackDuration, currentTime)
|
||||||
|
currentPlaybackSession?.syncData(syncData)
|
||||||
|
|
||||||
|
if (currentPlaybackSession?.progress?.isNaN() == true) {
|
||||||
|
Log.e(
|
||||||
|
tag,
|
||||||
|
"Current Playback Session invalid progress ${currentPlaybackSession?.progress} | Current Time: ${currentPlaybackSession?.currentTime} | Duration: ${currentPlaybackSession?.getTotalDuration()}"
|
||||||
|
)
|
||||||
|
return cb(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val hasNetworkConnection = DeviceManager.checkConnectivity(playerNotificationService)
|
||||||
|
|
||||||
|
// Save playback session to db (server linked sessions only)
|
||||||
|
// Sessions are removed once successfully synced with the server
|
||||||
|
currentPlaybackSession?.let { DeviceManager.dbManager.savePlaybackSession(it) }
|
||||||
|
|
||||||
|
if (currentIsLocal) {
|
||||||
|
// Save local progress sync
|
||||||
|
currentPlaybackSession?.let {
|
||||||
|
saveLocalProgress(it)
|
||||||
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"Sync local device current serverConnectionConfigId=${DeviceManager.serverConnectionConfig?.id}"
|
||||||
|
)
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Saved local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})")
|
||||||
|
|
||||||
|
// Local library item is linked to a server library item
|
||||||
|
// Send sync to server also if connected to this server and local item belongs to this
|
||||||
|
// server
|
||||||
|
val isConnectedToSameServer = it.serverConnectionConfigId != null && DeviceManager.serverConnectionConfig?.id == it.serverConnectionConfigId
|
||||||
|
if (hasNetworkConnection &&
|
||||||
|
shouldSyncServer &&
|
||||||
|
!it.libraryItemId.isNullOrEmpty() &&
|
||||||
|
isConnectedToSameServer
|
||||||
|
) {
|
||||||
|
apiHandler.sendLocalProgressSync(it) { syncSuccess, errorMsg ->
|
||||||
|
if (syncSuccess) {
|
||||||
|
failedSyncs = 0
|
||||||
|
playerNotificationService.alertSyncSuccess()
|
||||||
|
DeviceManager.dbManager.removePlaybackSession(it.id) // Remove session from db
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced local progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id})")
|
||||||
|
} else {
|
||||||
|
failedSyncs++
|
||||||
|
if (failedSyncs == 2) {
|
||||||
|
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||||
|
failedSyncs = 0
|
||||||
|
}
|
||||||
|
AbsLogger.error("MediaProgressSyncer", "sync: Local progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
}
|
||||||
|
|
||||||
|
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Not sending local progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${it.id}) (hasNetworkConnection: $hasNetworkConnection) (isConnectedToSameServer: $isConnectedToSameServer)")
|
||||||
|
cb(SyncResult(false, null, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (hasNetworkConnection && shouldSyncServer) {
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Sending progress sync to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
|
||||||
|
apiHandler.sendProgressSync(currentSessionId, syncData) { syncSuccess, errorMsg ->
|
||||||
|
if (syncSuccess) {
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Successfully synced progress (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: ${currentSessionId}) (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
|
||||||
|
failedSyncs = 0
|
||||||
|
playerNotificationService.alertSyncSuccess()
|
||||||
|
lastSyncTime = System.currentTimeMillis()
|
||||||
|
DeviceManager.dbManager.removePlaybackSession(currentSessionId) // Remove session from db
|
||||||
|
} else {
|
||||||
|
failedSyncs++
|
||||||
|
if (failedSyncs == 2) {
|
||||||
|
playerNotificationService.alertSyncFailing() // Show alert in client
|
||||||
|
failedSyncs = 0
|
||||||
|
}
|
||||||
|
AbsLogger.error("MediaProgressSyncer", "sync: Progress sync failed (count: $failedSyncs) (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName})")
|
||||||
|
}
|
||||||
|
cb(SyncResult(true, syncSuccess, errorMsg))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AbsLogger.info("MediaProgressSyncer", "sync: Not sending progress to server (title: \"$currentDisplayTitle\") (currentTime: $currentTime) (session id: $currentSessionId) (${DeviceManager.serverConnectionConfigName}) (hasNetworkConnection: $hasNetworkConnection)")
|
||||||
|
cb(SyncResult(false, null, null))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun saveLocalProgress(playbackSession: PlaybackSession) {
|
||||||
|
if (currentLocalMediaProgress == null) {
|
||||||
|
val mediaProgress =
|
||||||
|
DeviceManager.dbManager.getLocalMediaProgress(playbackSession.localMediaProgressId)
|
||||||
|
if (mediaProgress == null) {
|
||||||
|
currentLocalMediaProgress = playbackSession.getNewLocalMediaProgress()
|
||||||
|
} else {
|
||||||
|
currentLocalMediaProgress = mediaProgress
|
||||||
|
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentLocalMediaProgress?.updateFromPlaybackSession(playbackSession)
|
||||||
|
}
|
||||||
|
|
||||||
|
currentLocalMediaProgress?.let {
|
||||||
|
if (it.progress.isNaN()) {
|
||||||
|
Log.e(tag, "Invalid progress on local media progress")
|
||||||
|
} else {
|
||||||
|
DeviceManager.dbManager.saveLocalMediaProgress(it)
|
||||||
|
playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it)
|
||||||
|
Log.d(
|
||||||
|
tag,
|
||||||
|
"Saved Local Progress Current Time: ID ${it.id} | ${it.currentTime} | Duration ${it.duration} | Progress ${it.progressPercent}%"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun reset() {
|
||||||
|
currentPlaybackSession = null
|
||||||
|
currentLocalMediaProgress = null
|
||||||
|
lastSyncTime = 0L
|
||||||
|
Log.d(tag, "reset: Set last sync time 0 $lastSyncTime")
|
||||||
|
failedSyncs = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.audiobookshelf.app.media
|
||||||
|
|
||||||
|
import android.content.ContentResolver
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.AnyRes
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get uri to drawable or any other resource type if u wish
|
||||||
|
* @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))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* get uri to drawable or any other resource type if u wish
|
||||||
|
* @param drawableId - drawable res id
|
||||||
|
* @return - uri
|
||||||
|
*/
|
||||||
|
fun getUriToAbsIconDrawable(context: Context, absIconName: String): Uri {
|
||||||
|
val drawableId = when(absIconName) {
|
||||||
|
"audiobookshelf" -> R.drawable.abs_audiobookshelf
|
||||||
|
"authors" -> R.drawable.abs_authors
|
||||||
|
"book-1" -> R.drawable.abs_book_1
|
||||||
|
"books-1" -> R.drawable.abs_books_1
|
||||||
|
"books-2" -> R.drawable.abs_books_2
|
||||||
|
"columns" -> R.drawable.abs_columns
|
||||||
|
"database" -> R.drawable.abs_database
|
||||||
|
"file-picture" -> R.drawable.abs_file_picture
|
||||||
|
"headphones" -> R.drawable.abs_headphones
|
||||||
|
"heart" -> R.drawable.abs_heart
|
||||||
|
"microphone_1" -> R.drawable.abs_microphone_1
|
||||||
|
"microphone_2" -> R.drawable.abs_microphone_2
|
||||||
|
"microphone_3" -> R.drawable.abs_microphone_3
|
||||||
|
"music" -> R.drawable.abs_music
|
||||||
|
"podcast" -> R.drawable.abs_podcast
|
||||||
|
"radio" -> R.drawable.abs_radio
|
||||||
|
"rocket" -> R.drawable.abs_rocket
|
||||||
|
"rss" -> R.drawable.abs_rss
|
||||||
|
"star" -> R.drawable.abs_star
|
||||||
|
else -> R.drawable.icon_library_folder
|
||||||
|
}
|
||||||
|
return Uri.parse(
|
||||||
|
ContentResolver.SCHEME_ANDROID_RESOURCE
|
||||||
|
+ "://" + context.resources.getResourcePackageName(drawableId)
|
||||||
|
+ '/' + context.resources.getResourceTypeName(drawableId)
|
||||||
|
+ '/' + context.resources.getResourceEntryName(drawableId))
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package com.audiobookshelf.app.models
|
||||||
|
|
||||||
|
import com.audiobookshelf.app.data.LocalFolder
|
||||||
|
import com.audiobookshelf.app.data.MediaProgress
|
||||||
|
import com.audiobookshelf.app.data.MediaType
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
|
||||||
|
data class DownloadItem(
|
||||||
|
val id: String,
|
||||||
|
val libraryItemId: String,
|
||||||
|
val episodeId: String?,
|
||||||
|
val userMediaProgress: MediaProgress?,
|
||||||
|
val serverConnectionConfigId: String,
|
||||||
|
val serverAddress: String,
|
||||||
|
val serverUserId: String,
|
||||||
|
val mediaType: String,
|
||||||
|
val itemFolderPath: String,
|
||||||
|
val localFolder: LocalFolder,
|
||||||
|
val itemTitle: String,
|
||||||
|
val itemSubfolder: String,
|
||||||
|
val media: MediaType,
|
||||||
|
val downloadItemParts: MutableList<DownloadItemPart>,
|
||||||
|
@JsonIgnore var terminalFailureAt: Long? = null,
|
||||||
|
@JsonIgnore var stagingCleanupAt: Long? = null
|
||||||
|
) {
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isInternalStorage
|
||||||
|
get() = localFolder.id.startsWith("internal-")
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isDownloadFinished
|
||||||
|
get() = downloadItemParts.isNotEmpty() && downloadItemParts.all {
|
||||||
|
it.completed && it.moved && !it.isMoving && !it.failed
|
||||||
|
}
|
||||||
|
|
||||||
|
@JsonIgnore
|
||||||
|
fun getNextDownloadItemParts(limit: Int): MutableList<DownloadItemPart> {
|
||||||
|
val itemParts = mutableListOf<DownloadItemPart>()
|
||||||
|
if (limit == 0) return itemParts
|
||||||
|
|
||||||
|
for (it in downloadItemParts) {
|
||||||
|
if (!it.completed && !it.failed && it.downloadId == null) {
|
||||||
|
itemParts.add(it)
|
||||||
|
if (itemParts.size >= limit) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return itemParts
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package com.audiobookshelf.app.models
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.data.AudioTrack
|
||||||
|
import com.audiobookshelf.app.data.EBookFile
|
||||||
|
import com.audiobookshelf.app.data.LocalFolder
|
||||||
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
|
import com.audiobookshelf.app.device.DeviceManager
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
data class DownloadItemPart(
|
||||||
|
val id: String,
|
||||||
|
val downloadItemId: String,
|
||||||
|
val filename: String,
|
||||||
|
var fileSize: Long,
|
||||||
|
@JsonIgnore val destinationPath: String,
|
||||||
|
val finalDestinationPath:String,
|
||||||
|
val serverPath: String,
|
||||||
|
val localFolderName: String,
|
||||||
|
val localFolderUrl: String,
|
||||||
|
val localFolderId: String,
|
||||||
|
val ebookFile: EBookFile?,
|
||||||
|
val audioTrack: AudioTrack?,
|
||||||
|
val episode: PodcastEpisode?,
|
||||||
|
var completed:Boolean,
|
||||||
|
var moved:Boolean,
|
||||||
|
var isMoving:Boolean,
|
||||||
|
var failed:Boolean,
|
||||||
|
@JsonIgnore val uri: Uri,
|
||||||
|
@JsonIgnore val destinationUri: Uri,
|
||||||
|
@JsonIgnore val finalDestinationUri: Uri,
|
||||||
|
@JsonIgnore var completedDestinationUri: String?,
|
||||||
|
val finalDestinationSubfolder: String,
|
||||||
|
var downloadId: Long?,
|
||||||
|
@JsonIgnore var lastUpdateTime: Long?,
|
||||||
|
var progress: Long,
|
||||||
|
var bytesDownloaded: Long,
|
||||||
|
@JsonIgnore var retryCount: Int = 0,
|
||||||
|
@JsonIgnore var authRetryCount: Int = 0,
|
||||||
|
@JsonIgnore var waitingForSpace: Boolean = false,
|
||||||
|
@JsonIgnore var reusedExistingFile: Boolean = false
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
fun make(downloadItemId:String, filename:String, fileSize: Long, destinationFile: File, finalDestinationFile: File, subfolder:String, serverPath:String, localFolder: LocalFolder, ebookFile: EBookFile?, audioTrack: AudioTrack?, episode: PodcastEpisode?) :DownloadItemPart {
|
||||||
|
val destinationUri = Uri.fromFile(destinationFile)
|
||||||
|
val finalDestinationUri = Uri.fromFile(finalDestinationFile)
|
||||||
|
val rawCover = if (serverPath.endsWith("/cover")) "?raw=1" else ""
|
||||||
|
val downloadUri = Uri.parse("${DeviceManager.serverAddress}${serverPath}$rawCover")
|
||||||
|
|
||||||
|
Log.d("DownloadItemPart", "Audio File Destination Uri: $destinationUri | Final Destination Uri: $finalDestinationUri | Server Path $serverPath")
|
||||||
|
return DownloadItemPart(
|
||||||
|
id = DeviceManager.getBase64Id(finalDestinationFile.absolutePath),
|
||||||
|
downloadItemId,
|
||||||
|
filename = filename,
|
||||||
|
fileSize = fileSize,
|
||||||
|
destinationPath = destinationFile.absolutePath,
|
||||||
|
finalDestinationPath = finalDestinationFile.absolutePath,
|
||||||
|
serverPath = serverPath,
|
||||||
|
localFolderName = localFolder.name,
|
||||||
|
localFolderUrl = localFolder.contentUrl,
|
||||||
|
localFolderId = localFolder.id,
|
||||||
|
ebookFile = ebookFile,
|
||||||
|
audioTrack = audioTrack,
|
||||||
|
episode = episode,
|
||||||
|
completed = false,
|
||||||
|
moved = false,
|
||||||
|
isMoving = false,
|
||||||
|
failed = false,
|
||||||
|
uri = downloadUri,
|
||||||
|
destinationUri = destinationUri,
|
||||||
|
finalDestinationUri = finalDestinationUri,
|
||||||
|
completedDestinationUri = null,
|
||||||
|
finalDestinationSubfolder = subfolder,
|
||||||
|
downloadId = null,
|
||||||
|
lastUpdateTime = null,
|
||||||
|
progress = 0,
|
||||||
|
bytesDownloaded = 0,
|
||||||
|
reusedExistingFile = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@get:JsonIgnore
|
||||||
|
val isInternalStorage get() = localFolderId.startsWith("internal-")
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.audiobookshelf.app.models
|
||||||
|
|
||||||
|
import com.audiobookshelf.app.data.MediaProgress
|
||||||
|
import com.fasterxml.jackson.annotation.JsonIgnoreProperties
|
||||||
|
|
||||||
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
|
data class User(
|
||||||
|
val id:String,
|
||||||
|
val username: String,
|
||||||
|
val mediaProgress:List<MediaProgress>
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.ImageDecoder
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
|
import android.support.v4.media.session.MediaControllerCompat
|
||||||
|
import com.google.android.exoplayer2.Player
|
||||||
|
import com.google.android.exoplayer2.ui.PlayerNotificationManager
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
|
||||||
|
class AbMediaDescriptionAdapter (private val controller: MediaControllerCompat, private val playerNotificationService: PlayerNotificationService) : PlayerNotificationManager.MediaDescriptionAdapter {
|
||||||
|
private val tag = "MediaDescriptionAdapter"
|
||||||
|
|
||||||
|
private var currentIconUri: Uri? = null
|
||||||
|
private var currentBitmap: Bitmap? = null
|
||||||
|
|
||||||
|
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
|
||||||
|
val albumBitmap = controller.metadata.description.iconBitmap
|
||||||
|
?: controller.metadata.getBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART)
|
||||||
|
|
||||||
|
// Reuse bitmap from queue navigator (local) or PlaybackSession.resolveCoverBitmapAsync (streaming)
|
||||||
|
// For local cover images, bitmap is set in PlayerNotificationService TimelineQueueNavigator.getMediaDescription
|
||||||
|
if (albumBitmap != null) {
|
||||||
|
return albumBitmap
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
if (currentIconUri.toString().startsWith("content://")) {
|
||||||
|
currentBitmap = if (Build.VERSION.SDK_INT < 28) {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
MediaStore.Images.Media.getBitmap(playerNotificationService.contentResolver, currentIconUri)
|
||||||
|
} else {
|
||||||
|
val source: ImageDecoder.Source = ImageDecoder.createSource(playerNotificationService.contentResolver, currentIconUri!!)
|
||||||
|
ImageDecoder.decodeBitmap(source)
|
||||||
|
}
|
||||||
|
currentBitmap
|
||||||
|
} else {
|
||||||
|
serviceScope.launch {
|
||||||
|
currentBitmap = albumArtUri?.let {
|
||||||
|
resolveUriAsBitmap(playerNotificationService, it)
|
||||||
|
}
|
||||||
|
currentBitmap?.let { callback.onBitmap(it) }
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
currentBitmap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.support.v4.media.MediaMetadataCompat
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.audiobookshelf.app.data.*
|
||||||
|
import com.audiobookshelf.app.media.getUriToDrawable
|
||||||
|
|
||||||
|
class BrowseTree(
|
||||||
|
val context: Context,
|
||||||
|
itemsInProgress: List<ItemInProgress>,
|
||||||
|
libraries: List<Library>,
|
||||||
|
recentsLoaded: Boolean
|
||||||
|
) {
|
||||||
|
private val mediaIdToChildren = mutableMapOf<String, MutableList<MediaMetadataCompat>>()
|
||||||
|
|
||||||
|
init {
|
||||||
|
val rootList = mediaIdToChildren[AUTO_BROWSE_ROOT] ?: mutableListOf()
|
||||||
|
|
||||||
|
val continueListeningMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, CONTINUE_ROOT)
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Continue")
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.exo_icon_localaudio).toString())
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
val recentMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, RECENTLY_ROOT)
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Recent")
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.md_clock_outline).toString())
|
||||||
|
}.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 librariesMetadata = MediaMetadataCompat.Builder().apply {
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, LIBRARIES_ROOT)
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_TITLE, "Libraries")
|
||||||
|
putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI, getUriToDrawable(context, R.drawable.icon_library_folder).toString())
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
if (itemsInProgress.isNotEmpty()) {
|
||||||
|
rootList += continueListeningMetadata
|
||||||
|
}
|
||||||
|
|
||||||
|
if (libraries.isNotEmpty()) {
|
||||||
|
if (recentsLoaded) {
|
||||||
|
rootList += recentMetadata
|
||||||
|
}
|
||||||
|
rootList += librariesMetadata
|
||||||
|
|
||||||
|
libraries.forEach { library ->
|
||||||
|
// Skip libraries without audio content
|
||||||
|
if (library.stats?.numAudioFiles == 0) return@forEach
|
||||||
|
Log.d("BrowseTree", "Library $library | ${library.icon}")
|
||||||
|
// Generate library list items for Libraries menu
|
||||||
|
val libraryMediaMetadata = library.getMediaMetadata(context)
|
||||||
|
val children = mediaIdToChildren[LIBRARIES_ROOT] ?: mutableListOf()
|
||||||
|
children += libraryMediaMetadata
|
||||||
|
mediaIdToChildren[LIBRARIES_ROOT] = children
|
||||||
|
|
||||||
|
if (recentsLoaded) {
|
||||||
|
// Generate library list items for Recent menu
|
||||||
|
val recentlyMediaMetadata = library.getMediaMetadata(context,"recently")
|
||||||
|
val childrenRecently = mediaIdToChildren[RECENTLY_ROOT] ?: mutableListOf()
|
||||||
|
childrenRecently += recentlyMediaMetadata
|
||||||
|
mediaIdToChildren[RECENTLY_ROOT] = childrenRecently
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootList += downloadsMetadata
|
||||||
|
|
||||||
|
mediaIdToChildren[AUTO_BROWSE_ROOT] = rootList
|
||||||
|
}
|
||||||
|
|
||||||
|
operator fun get(mediaId: String) = mediaIdToChildren[mediaId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const val AUTO_BROWSE_ROOT = "/"
|
||||||
|
const val CONTINUE_ROOT = "__CONTINUE__"
|
||||||
|
const val DOWNLOADS_ROOT = "__DOWNLOADS__"
|
||||||
|
const val LIBRARIES_ROOT = "__LIBRARIES__"
|
||||||
|
const val RECENTLY_ROOT = "__RECENTLY__"
|
||||||
|
|
@ -0,0 +1,381 @@
|
||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.app.AlertDialog
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
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.SessionAvailabilityListener
|
||||||
|
import com.google.android.gms.cast.*
|
||||||
|
import com.google.android.gms.cast.framework.*
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class CastManager constructor(val mainActivity:Activity) {
|
||||||
|
private val tag = "CastManager"
|
||||||
|
|
||||||
|
private var playerNotificationService:PlayerNotificationService? = null
|
||||||
|
private var newConnectionListener: SessionListener? = null
|
||||||
|
private var castStateListener: CastStateListener? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The framework reports NO_DEVICES_AVAILABLE when a session ends, because it stops discovering -
|
||||||
|
* taken at face value that hides the cast button. The media router is the second opinion.
|
||||||
|
*/
|
||||||
|
private fun isReceiverAvailable(castState: Int): Boolean {
|
||||||
|
if (castState != CastState.NO_DEVICES_AVAILABLE) return true
|
||||||
|
|
||||||
|
// The framework's own selector, which carries the receiver id this app is configured with -
|
||||||
|
// building one by hand here would ask about a different receiver than the one being cast to
|
||||||
|
val selector = getContext().mergedSelector ?: return false
|
||||||
|
|
||||||
|
return getMediaRouter()?.isRouteAvailable(
|
||||||
|
selector,
|
||||||
|
MediaRouter.AVAILABILITY_FLAG_IGNORE_DEFAULT_ROUTE or
|
||||||
|
MediaRouter.AVAILABILITY_FLAG_REQUIRE_MATCH
|
||||||
|
) == true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drops what this manager registered for the activity, which does not outlive it. */
|
||||||
|
fun detach() {
|
||||||
|
castStateListener?.let { getContext().removeCastStateListener(it) }
|
||||||
|
castStateListener = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun switchToPlayer(useCastPlayer:Boolean) {
|
||||||
|
Handler(Looper.getMainLooper()).post() {
|
||||||
|
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() {
|
||||||
|
Log.d(tag, "SessionAvailabilityListener: 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(playerNotificationService: PlayerNotificationService, callback: RequestSessionCallback) {
|
||||||
|
this.playerNotificationService = playerNotificationService
|
||||||
|
|
||||||
|
mainActivity.runOnUiThread {
|
||||||
|
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 {
|
||||||
|
newConnectionListener?.let { ncl ->
|
||||||
|
getSessionManager()?.removeSessionManagerListener(ncl, 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)
|
||||||
|
session.castDevice?.let {
|
||||||
|
builder.setTitle(it.friendlyName)
|
||||||
|
}
|
||||||
|
builder.setOnDismissListener { callback.onCancel() }
|
||||||
|
builder.setPositiveButton("Stop Casting") { _, _ -> 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")
|
||||||
|
pluginCall?.resolve()
|
||||||
|
}
|
||||||
|
}, 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) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startRouteScan(connListener:ChromecastListener) {
|
||||||
|
// ChromecastListener has always implemented CastStateListener, but nothing registered it, so
|
||||||
|
// availability hung entirely on the media router callback below - which does not always fire
|
||||||
|
val stateListener = CastStateListener { state -> connListener.onReceiverAvailableUpdate(isReceiverAvailable(state)) }
|
||||||
|
castStateListener = stateListener
|
||||||
|
getContext().addCastStateListener(stateListener)
|
||||||
|
connListener.onReceiverAvailableUpdate(isReceiverAvailable(getContext().castState))
|
||||||
|
|
||||||
|
val 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
|
||||||
|
val session = getSessionManager()?.currentCastSession
|
||||||
|
// If we do have a session
|
||||||
|
if (session != null) {
|
||||||
|
// Let the client know
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "No cast devices available")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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?)
|
||||||
|
}
|
||||||
|
|
||||||
|
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?) {
|
||||||
|
Log.d(tag, "stopRouteScan")
|
||||||
|
if (callback == null) {
|
||||||
|
completionCallback?.run()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// mainActivity.runOnUiThread {
|
||||||
|
Log.d(tag, "Removing callback on media router")
|
||||||
|
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
|
||||||
|
newConnectionListener?.let { ncl ->
|
||||||
|
getSessionManager()?.removeSessionManagerListener(ncl, 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)
|
||||||
|
|
||||||
|
val castContext = CastContext.getSharedInstance(mainActivity)
|
||||||
|
|
||||||
|
playerNotificationService?.let {
|
||||||
|
if (it.castPlayer == null) {
|
||||||
|
Log.d(tag, "Initializing castPlayer on session started - switch to cast player")
|
||||||
|
it.castPlayer = CastPlayer(castContext).apply {
|
||||||
|
addListener(PlayerListener(it))
|
||||||
|
setSessionAvailabilityListener(CastSessionAvailabilityListener())
|
||||||
|
}
|
||||||
|
switchToPlayer(true)
|
||||||
|
} else {
|
||||||
|
Log.d(tag, "castPlayer is already initialized on session started")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionStartFailed(castSession: CastSession, error: Int) {
|
||||||
|
if (callback.onSessionStartFailed(error)) {
|
||||||
|
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSessionEnded(castSession: CastSession, error: Int) {
|
||||||
|
if (callback.onSessionEndedBeforeStart(error)) {
|
||||||
|
getSessionManager()?.removeSessionManagerListener(this, CastSession::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
newConnectionListener?.let {
|
||||||
|
Log.d(tag, "Add session manager listener")
|
||||||
|
getSessionManager()?.addSessionManagerListener(it, CastSession::class.java)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getContext(): CastContext {
|
||||||
|
return CastContext.getSharedInstance(mainActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSessionManager(): SessionManager? {
|
||||||
|
return getContext().sessionManager
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getMediaRouter(): MediaRouter? {
|
||||||
|
return MediaRouter.getInstance(mainActivity)
|
||||||
|
}
|
||||||
|
|
||||||
|
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,162 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2017 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import android.util.SparseArray
|
||||||
|
import android.util.SparseIntArray
|
||||||
|
import com.google.android.exoplayer2.C
|
||||||
|
import com.google.android.exoplayer2.MediaItem
|
||||||
|
import com.google.android.exoplayer2.Timeline
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/** A [Timeline] for Cast media queues. */ /* package */
|
||||||
|
class CastTimeline(itemIds: IntArray, itemIdToData: SparseArray<ItemData>) : Timeline() {
|
||||||
|
/** Holds [Timeline] related data for a Cast media item. */
|
||||||
|
class ItemData(
|
||||||
|
/** The duration of the item in microseconds, or [C.TIME_UNSET] if unknown. */
|
||||||
|
val durationUs: Long,
|
||||||
|
/**
|
||||||
|
* The default start position of the item in microseconds, or [C.TIME_UNSET] if unknown.
|
||||||
|
*/
|
||||||
|
var defaultPositionUs: Long, isLive: Boolean) {
|
||||||
|
/** Whether the item is live content, or `false` if unknown. */
|
||||||
|
val isLive: Boolean = isLive
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy of this instance with the given values.
|
||||||
|
*
|
||||||
|
* @param durationUs The duration in microseconds, or [C.TIME_UNSET] if unknown.
|
||||||
|
* @param defaultPositionUs The default start position in microseconds, or [C.TIME_UNSET]
|
||||||
|
* if unknown.
|
||||||
|
* @param isLive Whether the item is live, or `false` if unknown.
|
||||||
|
*/
|
||||||
|
fun copyWithNewValues(durationUs: Long, defaultPositionUs: Long, isLive: Boolean): ItemData {
|
||||||
|
return if (durationUs == this.durationUs && defaultPositionUs == this.defaultPositionUs && isLive == this.isLive) {
|
||||||
|
this
|
||||||
|
} else ItemData(durationUs, defaultPositionUs, isLive)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Holds no media information. */
|
||||||
|
val EMPTY = ItemData( /* durationUs= */
|
||||||
|
C.TIME_UNSET, /* defaultPositionUs= */
|
||||||
|
C.TIME_UNSET, /* isLive= */
|
||||||
|
false)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
private val idsToIndex: SparseIntArray
|
||||||
|
private val ids: IntArray
|
||||||
|
private val durationsUs: LongArray
|
||||||
|
private val defaultPositionsUs: LongArray
|
||||||
|
private val isLive: BooleanArray
|
||||||
|
|
||||||
|
// Timeline implementation.
|
||||||
|
override fun getWindowCount(): Int {
|
||||||
|
return ids.size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getWindow(windowIndex: Int, window: Window, defaultPositionProjectionUs: Long): Window {
|
||||||
|
val durationUs = durationsUs[windowIndex]
|
||||||
|
val isDynamic = durationUs == C.TIME_UNSET
|
||||||
|
val mediaItem = MediaItem.Builder().setUri(Uri.EMPTY).setTag(ids[windowIndex]).build()
|
||||||
|
return window.set( /* uid= */
|
||||||
|
ids[windowIndex], /* mediaItem= */
|
||||||
|
mediaItem, /* manifest= */
|
||||||
|
null, /* presentationStartTimeMs= */
|
||||||
|
C.TIME_UNSET, /* windowStartTimeMs= */
|
||||||
|
C.TIME_UNSET, /* elapsedRealtimeEpochOffsetMs= */
|
||||||
|
C.TIME_UNSET, /* isSeekable= */
|
||||||
|
!isDynamic,
|
||||||
|
isDynamic,
|
||||||
|
if (isLive[windowIndex]) mediaItem.liveConfiguration else null,
|
||||||
|
defaultPositionsUs[windowIndex],
|
||||||
|
durationUs, /* firstPeriodIndex= */
|
||||||
|
windowIndex, /* lastPeriodIndex= */
|
||||||
|
windowIndex, /* positionInFirstPeriodUs= */
|
||||||
|
0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPeriodCount(): Int {
|
||||||
|
return ids.size
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getPeriod(periodIndex: Int, period: Period, setIds: Boolean): Period {
|
||||||
|
val id = ids[periodIndex]
|
||||||
|
return period.set(id, id, periodIndex, durationsUs[periodIndex], 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getIndexOfPeriod(uid: Any): Int {
|
||||||
|
return if (uid is Int) idsToIndex[uid, C.INDEX_UNSET] else C.INDEX_UNSET
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getUidOfPeriod(periodIndex: Int): Int {
|
||||||
|
return ids[periodIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// equals and hashCode implementations.
|
||||||
|
override fun equals(other: Any?): Boolean {
|
||||||
|
if (this === other) {
|
||||||
|
return true
|
||||||
|
} else if (other !is CastTimeline) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val that = other
|
||||||
|
return (Arrays.equals(ids, that.ids)
|
||||||
|
&& Arrays.equals(durationsUs, that.durationsUs)
|
||||||
|
&& Arrays.equals(defaultPositionsUs, that.defaultPositionsUs)
|
||||||
|
&& Arrays.equals(isLive, that.isLive))
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
var result = Arrays.hashCode(ids)
|
||||||
|
result = 31 * result + Arrays.hashCode(durationsUs)
|
||||||
|
result = 31 * result + Arrays.hashCode(defaultPositionsUs)
|
||||||
|
result = 31 * result + Arrays.hashCode(isLive)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** [Timeline] for a cast queue that has no items. */
|
||||||
|
val EMPTY_CAST_TIMELINE = CastTimeline(IntArray(0), SparseArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Cast timeline from the given data.
|
||||||
|
*
|
||||||
|
* @param itemIds The ids of the items in the timeline.
|
||||||
|
* @param itemIdToData Maps item ids to [ItemData].
|
||||||
|
*/
|
||||||
|
init {
|
||||||
|
val itemCount = itemIds.size
|
||||||
|
idsToIndex = SparseIntArray(itemCount)
|
||||||
|
ids = itemIds.copyOf(itemCount)
|
||||||
|
durationsUs = LongArray(itemCount)
|
||||||
|
defaultPositionsUs = LongArray(itemCount)
|
||||||
|
isLive = BooleanArray(itemCount)
|
||||||
|
for (i in ids.indices) {
|
||||||
|
val id = ids[i]
|
||||||
|
idsToIndex.put(id, i)
|
||||||
|
val data = itemIdToData[id, ItemData.EMPTY]
|
||||||
|
durationsUs[i] = data.durationUs
|
||||||
|
defaultPositionsUs[i] = if (data.defaultPositionUs == C.TIME_UNSET) 0 else data.defaultPositionUs
|
||||||
|
isLive[i] = data.isLive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,109 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2018 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.util.SparseArray
|
||||||
|
import com.google.android.exoplayer2.C
|
||||||
|
import com.google.android.gms.cast.MediaInfo
|
||||||
|
import com.google.android.gms.cast.framework.media.RemoteMediaClient
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates [CastTimelines][CastTimeline] from cast receiver app status updates.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This class keeps track of the duration reported by the current item to fill any missing
|
||||||
|
* durations in the media queue items [See internal: b/65152553].
|
||||||
|
*/
|
||||||
|
/* package */
|
||||||
|
class CastTimelineTracker {
|
||||||
|
private val itemIdToData: SparseArray<CastTimeline.ItemData>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a [CastTimeline] that represents the state of the given `remoteMediaClient`.
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* Returned timelines may contain values obtained from `remoteMediaClient` in previous
|
||||||
|
* invocations of this method.
|
||||||
|
*
|
||||||
|
* @param remoteMediaClient The Cast media client.
|
||||||
|
* @return A [CastTimeline] that represents the given `remoteMediaClient` status.
|
||||||
|
*/
|
||||||
|
fun getCastTimeline(remoteMediaClient: RemoteMediaClient): CastTimeline {
|
||||||
|
val itemIds = remoteMediaClient.mediaQueue.itemIds
|
||||||
|
if (itemIds.size > 0) {
|
||||||
|
// Only remove unused items when there is something in the queue to avoid removing all entries
|
||||||
|
// if the remote media client clears the queue temporarily. See [Internal ref: b/128825216].
|
||||||
|
removeUnusedItemDataEntries(itemIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Reset state when the app instance changes [Internal ref: b/129672468].
|
||||||
|
val mediaStatus = remoteMediaClient.mediaStatus
|
||||||
|
?: return CastTimeline.EMPTY_CAST_TIMELINE
|
||||||
|
val currentItemId = mediaStatus.currentItemId
|
||||||
|
updateItemData(
|
||||||
|
currentItemId, mediaStatus.mediaInfo, /* defaultPositionUs= */C.TIME_UNSET)
|
||||||
|
for (item in mediaStatus.queueItems) {
|
||||||
|
val defaultPositionUs = (item.startTime * C.MICROS_PER_SECOND).toLong()
|
||||||
|
updateItemData(item.itemId, item.media, defaultPositionUs)
|
||||||
|
}
|
||||||
|
return CastTimeline(itemIds, itemIdToData)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateItemData(itemId: Int, mediaInfo: MediaInfo?, defaultPositionUs: Long) {
|
||||||
|
var defaultPositionUs = defaultPositionUs
|
||||||
|
val previousData = itemIdToData[itemId, CastTimeline.ItemData.EMPTY]
|
||||||
|
var durationUs = getStreamDurationUs(mediaInfo)
|
||||||
|
if (durationUs == C.TIME_UNSET) {
|
||||||
|
durationUs = previousData.durationUs
|
||||||
|
}
|
||||||
|
val isLive = if (mediaInfo == null) previousData.isLive else mediaInfo.streamType == MediaInfo.STREAM_TYPE_LIVE
|
||||||
|
if (defaultPositionUs == C.TIME_UNSET) {
|
||||||
|
defaultPositionUs = previousData.defaultPositionUs
|
||||||
|
}
|
||||||
|
itemIdToData.put(itemId, previousData.copyWithNewValues(durationUs, defaultPositionUs, isLive))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getStreamDurationUs(mediaInfo: MediaInfo?): Long {
|
||||||
|
if (mediaInfo == null) {
|
||||||
|
return C.TIME_UNSET
|
||||||
|
}
|
||||||
|
val durationMs = mediaInfo.streamDuration
|
||||||
|
return if (durationMs != MediaInfo.UNKNOWN_DURATION) msToUs(durationMs) else C.TIME_UNSET
|
||||||
|
}
|
||||||
|
|
||||||
|
fun msToUs(timeMs: Long): Long {
|
||||||
|
return if (timeMs == C.TIME_UNSET || timeMs == C.TIME_END_OF_SOURCE) timeMs else timeMs * 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun removeUnusedItemDataEntries(itemIds: IntArray) {
|
||||||
|
val scratchItemIds = HashSet<Int>( /* initialCapacity= */itemIds.size * 2)
|
||||||
|
for (id in itemIds) {
|
||||||
|
scratchItemIds.add(id)
|
||||||
|
}
|
||||||
|
var index = 0
|
||||||
|
while (index < itemIdToData.size()) {
|
||||||
|
if (!scratchItemIds.contains(itemIdToData.keyAt(index))) {
|
||||||
|
itemIdToData.removeAt(index)
|
||||||
|
} else {
|
||||||
|
index++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
itemIdToData = SparseArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,79 @@
|
||||||
|
/*
|
||||||
|
* Copyright (C) 2021 The Android Open Source Project
|
||||||
|
*
|
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
* you may not use this file except in compliance with the License.
|
||||||
|
* You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import com.google.android.exoplayer2.C
|
||||||
|
import com.google.android.exoplayer2.Format
|
||||||
|
import com.google.android.exoplayer2.source.TrackGroup
|
||||||
|
import com.google.android.exoplayer2.trackselection.TrackSelection
|
||||||
|
import com.google.android.exoplayer2.util.Assertions
|
||||||
|
|
||||||
|
/**
|
||||||
|
* [TrackSelection] that only selects the first track of the provided [TrackGroup].
|
||||||
|
*
|
||||||
|
*
|
||||||
|
* This relies on [CastPlayer] track groups only having one track.
|
||||||
|
*/
|
||||||
|
/* package */
|
||||||
|
internal class CastTrackSelection
|
||||||
|
/** @param trackGroup The [TrackGroup] from which the first track will only be selected.
|
||||||
|
*/(private val trackGroup: TrackGroup) : TrackSelection {
|
||||||
|
override fun getType(): Int {
|
||||||
|
return TrackSelection.TYPE_UNSET
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getTrackGroup(): TrackGroup {
|
||||||
|
return trackGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun length(): Int {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFormat(index: Int): Format {
|
||||||
|
Assertions.checkArgument(index == 0)
|
||||||
|
return trackGroup.getFormat(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getIndexInTrackGroup(index: Int): Int {
|
||||||
|
return if (index == 0) 0 else C.INDEX_UNSET
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun indexOf(format: Format): Int {
|
||||||
|
return if (format === trackGroup.getFormat(0)) 0 else C.INDEX_UNSET
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun indexOf(indexInTrackGroup: Int): Int {
|
||||||
|
return if (indexInTrackGroup == 0) 0 else C.INDEX_UNSET
|
||||||
|
}
|
||||||
|
|
||||||
|
// Object overrides.
|
||||||
|
override fun hashCode(): Int {
|
||||||
|
return System.identityHashCode(trackGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track groups are compared by identity not value, as distinct groups may have the same value.
|
||||||
|
override fun equals(obj: Any?): Boolean {
|
||||||
|
if (this === obj) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (obj == null || javaClass != obj.javaClass) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
val other = obj as CastTrackSelection
|
||||||
|
return trackGroup === other.trackGroup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.net.Uri
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.BuildConfig
|
||||||
|
import com.audiobookshelf.app.R
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
private const val TAG = "CoverImageLoader"
|
||||||
|
|
||||||
|
/** Loads [uri] as a bitmap via Glide, falling back to the app icon if the load fails. */
|
||||||
|
suspend fun resolveUriAsBitmap(context: Context, uri: Uri): Bitmap? {
|
||||||
|
return withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.load(uri)
|
||||||
|
.placeholder(R.drawable.icon)
|
||||||
|
.error(R.drawable.icon)
|
||||||
|
.submit()
|
||||||
|
.get()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to load cover bitmap for uri: $uri", e)
|
||||||
|
|
||||||
|
Glide.with(context)
|
||||||
|
.asBitmap()
|
||||||
|
.load(Uri.parse("android.resource://${BuildConfig.APPLICATION_ID}/" + R.drawable.icon))
|
||||||
|
.submit()
|
||||||
|
.get()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,286 @@
|
||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.*
|
||||||
|
import android.support.v4.media.session.MediaSessionCompat
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.KeyEvent
|
||||||
|
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||||
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
|
import java.util.*
|
||||||
|
import kotlin.concurrent.schedule
|
||||||
|
|
||||||
|
class MediaSessionCallback(var playerNotificationService:PlayerNotificationService) : MediaSessionCompat.Callback() {
|
||||||
|
var tag = "MediaSessionCallback"
|
||||||
|
|
||||||
|
private var mediaButtonClickCount: Int = 0
|
||||||
|
private var mediaButtonClickTimeout: Long = 1000 //ms
|
||||||
|
|
||||||
|
override fun onPrepare() {
|
||||||
|
Log.d(tag, "ON PREPARE MEDIA SESSION COMPAT")
|
||||||
|
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
||||||
|
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
|
if (it == null) {
|
||||||
|
Log.e(tag, "Failed to play library item")
|
||||||
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
playerNotificationService.preparePlayer(it,true, playbackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlay() {
|
||||||
|
Log.d(tag, "ON PLAY MEDIA SESSION COMPAT")
|
||||||
|
playerNotificationService.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareFromSearch(query: String?, extras: Bundle?) {
|
||||||
|
Log.d(tag, "ON PREPARE FROM SEARCH $query")
|
||||||
|
super.onPrepareFromSearch(query, extras)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayFromSearch(query: String?, extras: Bundle?) {
|
||||||
|
Log.d(tag, "ON PLAY FROM SEARCH $query")
|
||||||
|
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
||||||
|
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
|
if (it == null) {
|
||||||
|
Log.e(tag, "Failed to play library item")
|
||||||
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
playerNotificationService.preparePlayer(it, true, playbackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPause() {
|
||||||
|
Log.d(tag, "ON PAUSE MEDIA SESSION COMPAT")
|
||||||
|
playerNotificationService.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
playerNotificationService.pause()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSkipToPrevious() {
|
||||||
|
playerNotificationService.skipToPrevious()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSkipToNext() {
|
||||||
|
playerNotificationService.skipToNext()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onFastForward() {
|
||||||
|
playerNotificationService.jumpForward()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRewind() {
|
||||||
|
playerNotificationService.jumpBackward()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSeekTo(pos: Long) {
|
||||||
|
val currentTrackStartOffset = playerNotificationService.getCurrentTrackStartOffsetMs()
|
||||||
|
playerNotificationService.seekPlayer(currentTrackStartOffset + pos)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onChangeSpeed() {
|
||||||
|
// cycle to next speed, only contains preset android app options, as each increment needs it's own icon
|
||||||
|
// Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the phone app
|
||||||
|
val mediaManager = playerNotificationService.mediaManager
|
||||||
|
val newSpeed = when (mediaManager.getSavedPlaybackRate()) {
|
||||||
|
in 0.5f..0.7f -> 1.0f
|
||||||
|
in 0.8f..1.0f -> 1.2f
|
||||||
|
in 1.1f..1.2f -> 1.5f
|
||||||
|
in 1.3f..1.5f -> 2.0f
|
||||||
|
in 1.6f..2.0f -> 3.0f
|
||||||
|
in 2.1f..3.0f -> 0.5f
|
||||||
|
// anything set above 3 (can happen in the android app) will be reset to 1
|
||||||
|
else -> 1.0f
|
||||||
|
}
|
||||||
|
mediaManager.setSavedPlaybackRate(newSpeed)
|
||||||
|
playerNotificationService.setPlaybackSpeed(newSpeed)
|
||||||
|
playerNotificationService.clientEventEmitter?.onPlaybackSpeedChanged(newSpeed)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlayFromMediaId(mediaId: String?, extras: Bundle?) {
|
||||||
|
Log.d(tag, "ON PLAY FROM MEDIA ID $mediaId")
|
||||||
|
val libraryItemWrapper: LibraryItemWrapper?
|
||||||
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
|
|
||||||
|
if (mediaId.isNullOrEmpty()) {
|
||||||
|
libraryItemWrapper = playerNotificationService.mediaManager.getFirstItem()
|
||||||
|
} else {
|
||||||
|
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||||
|
if (libraryItemWithEpisode != null) {
|
||||||
|
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
|
||||||
|
podcastEpisode = libraryItemWithEpisode.episode
|
||||||
|
} else {
|
||||||
|
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||||
|
if (libraryItemWrapper == null) {
|
||||||
|
Log.e(tag, "onPlayFromMediaId: Media item not found $mediaId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryItemWrapper?.let { li ->
|
||||||
|
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
|
if (it == null) {
|
||||||
|
Log.e(tag, "Failed to play library item")
|
||||||
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
playerNotificationService.preparePlayer(it, true, playbackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMediaButtonEvent(mediaButtonEvent: Intent): Boolean {
|
||||||
|
return handleCallMediaButton(mediaButtonEvent)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleCallMediaButton(intent: Intent): Boolean {
|
||||||
|
Log.w(tag, "handleCallMediaButton $intent | ${intent.action}")
|
||||||
|
|
||||||
|
if(Intent.ACTION_MEDIA_BUTTON == intent.action) {
|
||||||
|
val keyEvent = if (Build.VERSION.SDK_INT >= 33) {
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT, KeyEvent::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent.getParcelableExtra(Intent.EXTRA_KEY_EVENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(tag, "handleCallMediaButton keyEvent = $keyEvent | action ${keyEvent?.action}")
|
||||||
|
|
||||||
|
// Widget button intent is only sending the action down event
|
||||||
|
if (keyEvent?.action == KeyEvent.ACTION_DOWN) {
|
||||||
|
Log.d(tag, "handleCallMediaButton: key action_down for ${keyEvent.keyCode}")
|
||||||
|
when (keyEvent.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY_PAUSE -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Media Play/Pause")
|
||||||
|
|
||||||
|
// TODO: Play/pause event sent from widget when app is closed. Currently the service gets destroyed before anything can happen
|
||||||
|
// if (playerNotificationService.currentPlaybackSession == null && DeviceManager.deviceData.lastPlaybackSession != null) {
|
||||||
|
// Log.i(tag, "No playback session but had one in the db")
|
||||||
|
//
|
||||||
|
// val connectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == DeviceManager.deviceData.lastPlaybackSession?.serverConnectionConfigId }
|
||||||
|
// connectionConfig?.let {
|
||||||
|
// Log.i(tag, "Setting playback session from db $it")
|
||||||
|
// DeviceManager.serverConnectionConfig = it
|
||||||
|
//
|
||||||
|
// playerNotificationService.currentPlaybackSession = DeviceManager.deviceData.lastPlaybackSession
|
||||||
|
// playerNotificationService.startNewPlaybackSession()
|
||||||
|
// return true
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
if (playerNotificationService.mPlayer.isPlaying) {
|
||||||
|
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||||
|
handleMediaButtonClickCount()
|
||||||
|
} else {
|
||||||
|
if (0 == mediaButtonClickCount) {
|
||||||
|
playerNotificationService.play()
|
||||||
|
}
|
||||||
|
handleMediaButtonClickCount()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Media Fast Forward")
|
||||||
|
playerNotificationService.jumpForward()
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_REWIND -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Media Rewind")
|
||||||
|
playerNotificationService.jumpBackward()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyEvent?.action == KeyEvent.ACTION_UP) {
|
||||||
|
Log.d(tag, "handleCallMediaButton: key action_up for ${keyEvent.keyCode}")
|
||||||
|
when (keyEvent.keyCode) {
|
||||||
|
KeyEvent.KEYCODE_HEADSETHOOK -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Headset Hook")
|
||||||
|
if (0 == mediaButtonClickCount) {
|
||||||
|
if (playerNotificationService.mPlayer.isPlaying)
|
||||||
|
playerNotificationService.pause()
|
||||||
|
else
|
||||||
|
playerNotificationService.play()
|
||||||
|
}
|
||||||
|
handleMediaButtonClickCount()
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PLAY -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Media Play")
|
||||||
|
if (0 == mediaButtonClickCount) {
|
||||||
|
playerNotificationService.play()
|
||||||
|
}
|
||||||
|
handleMediaButtonClickCount()
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PAUSE -> {
|
||||||
|
Log.d(tag, "handleCallMediaButton: Media Pause")
|
||||||
|
if (0 == mediaButtonClickCount) playerNotificationService.pause()
|
||||||
|
handleMediaButtonClickCount()
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_NEXT -> {
|
||||||
|
playerNotificationService.jumpForward()
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_PREVIOUS -> {
|
||||||
|
playerNotificationService.jumpBackward()
|
||||||
|
}
|
||||||
|
KeyEvent.KEYCODE_MEDIA_STOP -> {
|
||||||
|
playerNotificationService.pause()
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Log.d(tag, "KeyCode:${keyEvent.keyCode}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMediaButtonClickCount() {
|
||||||
|
mediaButtonClickCount++
|
||||||
|
if (1 == mediaButtonClickCount) {
|
||||||
|
Timer().schedule(mediaButtonClickTimeout) {
|
||||||
|
mediaBtnHandler.sendEmptyMessage(mediaButtonClickCount)
|
||||||
|
mediaButtonClickCount = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private val mediaBtnHandler : Handler = @SuppressLint("HandlerLeak")
|
||||||
|
object : Handler(){
|
||||||
|
override fun handleMessage(msg: Message) {
|
||||||
|
super.handleMessage(msg)
|
||||||
|
if (2 == msg.what) {
|
||||||
|
playerNotificationService.jumpBackward()
|
||||||
|
playerNotificationService.play()
|
||||||
|
}
|
||||||
|
else if (msg.what >= 3) {
|
||||||
|
playerNotificationService.jumpForward()
|
||||||
|
playerNotificationService.play()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCustomAction(action: String?, extras: Bundle?) {
|
||||||
|
super.onCustomAction(action, extras)
|
||||||
|
|
||||||
|
when (action) {
|
||||||
|
CUSTOM_ACTION_JUMP_FORWARD -> onFastForward()
|
||||||
|
CUSTOM_ACTION_JUMP_BACKWARD -> onRewind()
|
||||||
|
CUSTOM_ACTION_SKIP_FORWARD -> onSkipToNext()
|
||||||
|
CUSTOM_ACTION_SKIP_BACKWARD -> onSkipToPrevious()
|
||||||
|
CUSTOM_ACTION_CHANGE_SPEED -> onChangeSpeed()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.audiobookshelf.app.player
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.os.ResultReceiver
|
||||||
|
import android.support.v4.media.session.PlaybackStateCompat
|
||||||
|
import android.util.Log
|
||||||
|
import com.audiobookshelf.app.data.LibraryItemWrapper
|
||||||
|
import com.audiobookshelf.app.data.PodcastEpisode
|
||||||
|
import com.google.android.exoplayer2.Player
|
||||||
|
import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector
|
||||||
|
|
||||||
|
class MediaSessionPlaybackPreparer(var playerNotificationService:PlayerNotificationService) : MediaSessionConnector.PlaybackPreparer {
|
||||||
|
var tag = "MediaSessionPlaybackPreparer"
|
||||||
|
|
||||||
|
override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?): Boolean {
|
||||||
|
Log.d(tag, "ON COMMAND $command")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getSupportedPrepareActions(): Long {
|
||||||
|
return PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID or
|
||||||
|
PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID or
|
||||||
|
PlaybackStateCompat.ACTION_PREPARE_FROM_SEARCH or
|
||||||
|
PlaybackStateCompat.ACTION_PLAY_FROM_SEARCH
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepare(playWhenReady: Boolean) {
|
||||||
|
Log.d(tag, "ON PREPARE $playWhenReady")
|
||||||
|
playerNotificationService.mediaManager.getFirstItem()?.let { li ->
|
||||||
|
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
|
if (it == null) {
|
||||||
|
Log.e(tag, "Failed to play library item")
|
||||||
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||||
|
Log.d(tag, "ON PREPARE FROM MEDIA ID $mediaId $playWhenReady")
|
||||||
|
|
||||||
|
val libraryItemWrapper: LibraryItemWrapper?
|
||||||
|
var podcastEpisode: PodcastEpisode? = null
|
||||||
|
|
||||||
|
val libraryItemWithEpisode = playerNotificationService.mediaManager.getPodcastWithEpisodeByEpisodeId(mediaId)
|
||||||
|
if (libraryItemWithEpisode != null) {
|
||||||
|
libraryItemWrapper = libraryItemWithEpisode.libraryItemWrapper
|
||||||
|
podcastEpisode = libraryItemWithEpisode.episode
|
||||||
|
} else {
|
||||||
|
libraryItemWrapper = playerNotificationService.mediaManager.getById(mediaId)
|
||||||
|
}
|
||||||
|
|
||||||
|
libraryItemWrapper?.let { li ->
|
||||||
|
playerNotificationService.mediaManager.play(li, podcastEpisode, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
|
if (it == null) {
|
||||||
|
Log.e(tag, "Failed to play library item")
|
||||||
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {
|
||||||
|
Log.d(tag, "ON PREPARE FROM SEARCH $query")
|
||||||
|
playerNotificationService.mediaManager.getFromSearch(query)?.let { li ->
|
||||||
|
playerNotificationService.mediaManager.play(li, null, playerNotificationService.getPlayItemRequestPayload(false)) {
|
||||||
|
if (it == null) {
|
||||||
|
Log.e(tag, "Failed to play library item")
|
||||||
|
} else {
|
||||||
|
val playbackRate = playerNotificationService.mediaManager.getSavedPlaybackRate()
|
||||||
|
Handler(Looper.getMainLooper()).post {
|
||||||
|
playerNotificationService.preparePlayer(it, playWhenReady, playbackRate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {
|
||||||
|
Log.d(tag, "ON PREPARE FROM URI $uri")
|
||||||
|
}
|
||||||
|
}
|
||||||