commit fc946702c1dbe449b6e2d71169244b3e671e2846 Author: FabioMich66 Date: Sat Oct 3 13:38:18 2026 +0200 first commit diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fd66659 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..1af2bb8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -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.' diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d04ad81 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..6ee8787 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -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. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..165671c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,41 @@ + + +## Brief summary + + + +## Which issue is fixed? + + + +## Pull Request Type + + + +## In-depth Description + + + +## How have you tested this? + + + +## Screenshots + + diff --git a/.github/testing-page-template.html b/.github/testing-page-template.html new file mode 100644 index 0000000..8a3d33f --- /dev/null +++ b/.github/testing-page-template.html @@ -0,0 +1,81 @@ + + + + + +Audiobookshelf :: Testers + + + + + + + +

Audiobookshelf :: Testers

+ +

+ Here you can download the latest version of Audiobookshelf for Android for testing. + The app is automatically built for every new patch added to the project. +

+ +
+
Built on
+
__DATE__
+ +
Git commit
+ __COMMIT__ + +
Download
+ __APK__ +
+ +

+ Using this requires some technical knowledge to install the APK manually. + Regular users should use the releases published on the Play Store. +

+ +
+ +

Report bugs, request features, provide feedback, and contribute on GitHub

+

+ + + + + + + + + diff --git a/.github/workflows/build-apk.yml b/.github/workflows/build-apk.yml new file mode 100644 index 0000000..a486a50 --- /dev/null +++ b/.github/workflows/build-apk.yml @@ -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 diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml new file mode 100644 index 0000000..59825c3 --- /dev/null +++ b/.github/workflows/build-ios.yml @@ -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 diff --git a/.github/workflows/close-issues-on-release.yml b/.github/workflows/close-issues-on-release.yml new file mode 100644 index 0000000..9c59075 --- /dev/null +++ b/.github/workflows/close-issues-on-release.yml @@ -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}). diff --git a/.github/workflows/close_blank_issues.yaml b/.github/workflows/close_blank_issues.yaml new file mode 100644 index 0000000..c710882 --- /dev/null +++ b/.github/workflows/close_blank_issues.yaml @@ -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" + }); + } \ No newline at end of file diff --git a/.github/workflows/deploy-apk.yml b/.github/workflows/deploy-apk.yml new file mode 100644 index 0000000..c1b81c2 --- /dev/null +++ b/.github/workflows/deploy-apk.yml @@ -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 diff --git a/.github/workflows/i18n-check.yml b/.github/workflows/i18n-check.yml new file mode 100644 index 0000000..a2eb73e --- /dev/null +++ b/.github/workflows/i18n-check.yml @@ -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 diff --git a/.github/workflows/translate-credits.yml b/.github/workflows/translate-credits.yml new file mode 100644 index 0000000..74fbb97 --- /dev/null +++ b/.github/workflows/translate-credits.yml @@ -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 }}" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..551e9ac --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..0414236 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,17 @@ +{ + "semi": false, + "singleQuote": true, + "printWidth": 400, + "proseWrap": "never", + "trailingComma": "none", + "overrides": [ + { + "files": ["*.html"], + "options": { + "singleQuote": false, + "wrapAttributes": false, + "sortAttributes": false + } + } + ] +} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..0e06f63 --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,7 @@ +{ + "recommendations": [ + "EditorConfig.EditorConfig", + "esbenp.prettier-vscode", + "octref.vetur" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..0debc1d --- /dev/null +++ b/.vscode/settings.json @@ -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" + } +} diff --git a/INSTALL.md b/INSTALL.md new file mode 100644 index 0000000..807b91c --- /dev/null +++ b/INSTALL.md @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f40e2f0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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 . + +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 +. + + 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 +. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..63c86fe --- /dev/null +++ b/android/.gitignore @@ -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 diff --git a/android/.vscode/settings.json b/android/.vscode/settings.json new file mode 100644 index 0000000..b621253 --- /dev/null +++ b/android/.vscode/settings.json @@ -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" +} diff --git a/android/app/.gitignore b/android/app/.gitignore new file mode 100644 index 0000000..043df80 --- /dev/null +++ b/android/app/.gitignore @@ -0,0 +1,2 @@ +/build/* +!/build/.npmkeep diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..78393d9 --- /dev/null +++ b/android/app/build.gradle @@ -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' diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle new file mode 100644 index 0000000..47375cf --- /dev/null +++ b/android/app/capacitor.build.gradle @@ -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() +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 0000000..f1b4245 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -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 diff --git a/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java new file mode 100644 index 0000000..f2c2217 --- /dev/null +++ b/android/app/src/androidTest/java/com/getcapacitor/myapp/ExampleInstrumentedTest.java @@ -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 Testing documentation + */ +@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()); + } +} diff --git a/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80bcc6f --- /dev/null +++ b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..80bcc6f --- /dev/null +++ b/android/app/src/debug/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..dc6df99 Binary files /dev/null and b/android/app/src/debug/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher_background.png b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..9fb5816 Binary files /dev/null and b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..da357ca Binary files /dev/null and b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..dc6df99 Binary files /dev/null and b/android/app/src/debug/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..2aa9e1a Binary files /dev/null and b/android/app/src/debug/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher_background.png b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..4854004 Binary files /dev/null and b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..45cd75f Binary files /dev/null and b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..2aa9e1a Binary files /dev/null and b/android/app/src/debug/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..8a8ff5f Binary files /dev/null and b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_background.png b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..818c745 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..189bd83 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..8a8ff5f Binary files /dev/null and b/android/app/src/debug/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..96edad9 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_background.png b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..a780d0f Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..b0db2ab Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..96edad9 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..bda38f3 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..217cec6 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..8ad3288 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..bda38f3 Binary files /dev/null and b/android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/debug/res/values/strings.xml b/android/app/src/debug/res/values/strings.xml new file mode 100644 index 0000000..6672b67 --- /dev/null +++ b/android/app/src/debug/res/values/strings.xml @@ -0,0 +1,14 @@ + + + Audiobookshelf + Audiobookshelf + com.audiobookshelf.app + audiobookshelf + Add widget + Simple widget for audiobookshelf playback + Jump Forward + Jump Backward + Skip Forward + Skip Backward + Change Playback Speed + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..3c7cadc --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/assets/capacitor.config.json b/android/app/src/main/assets/capacitor.config.json new file mode 100644 index 0000000..0551bb2 --- /dev/null +++ b/android/app/src/main/assets/capacitor.config.json @@ -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" + } +} diff --git a/android/app/src/main/assets/capacitor.plugins.json b/android/app/src/main/assets/capacitor.plugins.json new file mode 100644 index 0000000..b5bb88b --- /dev/null +++ b/android/app/src/main/assets/capacitor.plugins.json @@ -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" + } +] diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..c9397c0 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt b/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt new file mode 100644 index 0000000..c1f9238 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/CastOptionsProvider.kt @@ -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? { + return null + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt new file mode 100644 index 0000000..b1bfac7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/MainActivity.kt @@ -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 { + 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() + 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, 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) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/MediaPlayerWidget.kt b/android/app/src/main/java/com/audiobookshelf/app/MediaPlayerWidget.kt new file mode 100644 index 0000000..acb015a --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/MediaPlayerWidget.kt @@ -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?) { + 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) +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt b/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt new file mode 100644 index 0000000..a7dd1b8 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/AudioTrack.kt @@ -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) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt b/android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt new file mode 100644 index 0000000..6369071 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/CollapsedSeries.kt @@ -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 +) : 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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt new file mode 100644 index 0000000..f6e8800 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DataClasses.kt @@ -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 { return mutableListOf() } + @JsonIgnore + open fun setAudioTracks(audioTracks:MutableList) { } + @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, + var episodes:MutableList?, + var autoDownloadEpisodes:Boolean, + var numEpisodes:Int? +) : MediaType(metadata, coverPath) { + @JsonIgnore + override fun getAudioTracks():List { + val tracks = episodes?.map { it.audioTrack } + return tracks?.filterNotNull() ?: mutableListOf() + } + @JsonIgnore + override fun setAudioTracks(audioTracks:MutableList) { + // Remove episodes no longer there in tracks + episodes = episodes?.filter { ep -> + audioTracks.find { it.localFileId == ep.audioTrack?.localFileId } != null + } as MutableList + + // 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, + var audioFiles:List?, + var chapters:List?, + var tracks:MutableList?, + var ebookFile: EBookFile?, + var size:Long?, + var duration:Double?, + var numTracks:Int? +) : MediaType(metadata, coverPath) { + @JsonIgnore + override fun getAudioTracks():List { + return tracks ?: mutableListOf() + } + @JsonIgnore + override fun setAudioTracks(audioTracks:MutableList) { + 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?, + var narrators:MutableList?, + var genres:MutableList, + 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? +) : 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, + 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?, + 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, + 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? +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItemSearchResultLibraryItemType( + val libraryItem: LibraryItem +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class LibraryItemSearchResultType( + var book:List?, + var podcast:List?, + var series:List?, + var authors:List? +) + +// 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? +) : 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? +) : 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? +) : 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? +) : 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? +) : LibraryShelfType(id, label, total, type) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt new file mode 100644 index 0000000..fc9beb5 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/DeviceClasses.kt @@ -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? +) + +@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, + var lastServerConnectionConfigId: String?, + var deviceSettings: DeviceSettings?, + var lastPlaybackSession: PlaybackSession? +) { + @JsonIgnore + fun getLastServerConnectionConfig(): ServerConnectionConfig? { + return lastServerConnectionConfigId?.let { lsccid -> + return serverConnectionConfigs.find { it.id == lsccid } + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/EBookFile.kt b/android/app/src/main/java/com/audiobookshelf/app/data/EBookFile.kt new file mode 100644 index 0000000..88bdd8b --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/EBookFile.kt @@ -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? +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/ItemInProgress.kt b/android/app/src/main/java/com/audiobookshelf/app/data/ItemInProgress.kt new file mode 100644 index 0000000..77d4c70 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/ItemInProgress.kt @@ -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(serverItem.toString()) + + var episode:PodcastEpisode? = null + if (serverItem.has("recentEpisode")) { + episode = jacksonMapper.readValue(serverItem.get("recentEpisode").toString()) + } + + val progressLastUpdate:Long = serverItem.getLong("progressLastUpdate") + return ItemInProgress(libraryItem, episode, progressLastUpdate, false) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt new file mode 100644 index 0000000..97ce555 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryAuthorItem.kt @@ -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?, + var series:MutableList? +) : 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) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCategory.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCategory.kt new file mode 100644 index 0000000..2ab2bc9 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCategory.kt @@ -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, + var isLocal:Boolean +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt new file mode 100644 index 0000000..e43985b --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryCollection.kt @@ -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?, +) : 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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt new file mode 100644 index 0000000..0b08f1a --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibraryItem.kt @@ -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?, + 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 + 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) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt new file mode 100644 index 0000000..47ff50c --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LibrarySeriesItem.kt @@ -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?, + 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) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt new file mode 100644 index 0000000..d1c513c --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalLibraryItem.kt @@ -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, + 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, _localFiles:MutableList) { + 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 + 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 + 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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt new file mode 100644 index 0000000..11673e8 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaItem.kt @@ -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, + var ebookFile: EBookFile?, + var localFiles: MutableList, + 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) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt new file mode 100644 index 0000000..be86355 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/LocalMediaProgress.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemEvent.kt b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemEvent.kt new file mode 100644 index 0000000..a5be2f9 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemEvent.kt @@ -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 +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemHistory.kt b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemHistory.kt new file mode 100644 index 0000000..7b1d094 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/MediaItemHistory.kt @@ -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, +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/MediaProgress.kt b/android/app/src/main/java/com/audiobookshelf/app/data/MediaProgress.kt new file mode 100644 index 0000000..2f256b8 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/MediaProgress.kt @@ -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" +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackMetadata.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackMetadata.kt new file mode 100644 index 0000000..33cfa9d --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackMetadata.kt @@ -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 +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt new file mode 100644 index 0000000..6caba55 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/data/PlaybackSession.kt @@ -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, + 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, + 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 { + val mediaItems: MutableList = 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 + ) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt new file mode 100644 index 0000000..de931ca --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/device/DeviceManager.kt @@ -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 + ) + } + } + }) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt new file mode 100644 index 0000000..9051149 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/device/FolderScanner.kt @@ -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() + 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) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt new file mode 100644 index 0000000..63c97a7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DbManager.kt @@ -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 { + val localLibraryItems: MutableList = 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 { + 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) { + 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 { + val localFolders: MutableList = mutableListOf() + Paper.book("localFolders").allKeys.forEach { localFolderId -> + Paper.book("localFolders").read(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 { + val downloadItems: MutableList = mutableListOf() + Paper.book("downloadItems").allKeys.forEach { downloadItemId -> + Paper.book("downloadItems").read(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 { + val mediaProgress: MutableList = mutableListOf() + Paper.book("localMediaProgress").allKeys.forEach { localMediaProgressId -> + Paper.book("localMediaProgress").read(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 + + // 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 + } 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 + } + + // 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 { + val sessions: MutableList = mutableListOf() + Paper.book("playbackSession").allKeys.forEach { playbackSessionId -> + Paper.book("playbackSession").read(playbackSessionId)?.let { + sessions.add(it) + } + } + return sessions + } + + fun saveLog(log: AbsLog) { + Paper.book("log").write(log.id, log) + } + fun getAllLogs(): List { + val logs: MutableList = mutableListOf() + Paper.book("log").allKeys.forEach { logId -> + Paper.book("log").read(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" + ) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt new file mode 100644 index 0000000..7a25ddd --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/DownloadItemManager.kt @@ -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() + private val safFolderLocks = ConcurrentHashMap() + private val scanLocks = ConcurrentHashMap() + private val reservations = mutableMapOf() + private val lastPersistTime = mutableMapOf() + private val finalizingItems = mutableSetOf() + private val refreshingServerIds = mutableSetOf() + private val apiHandler = ApiHandler(context) + private var watcherRunning = false + private val jacksonMapper = + jacksonObjectMapper() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + var downloadItemQueue: MutableList = mutableListOf() + private set + var currentDownloadItemParts: MutableList = 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 + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt new file mode 100644 index 0000000..fdb90a4 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/IncompleteDownloadCleanup.kt @@ -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() + .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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt new file mode 100644 index 0000000..f9a2217 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/InternalDownloadManager.kt @@ -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() + + 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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt new file mode 100644 index 0000000..c294f52 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/SecureStorage.kt @@ -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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/managers/SleepTimerManager.kt b/android/app/src/main/java/com/audiobookshelf/app/managers/SleepTimerManager.kt new file mode 100644 index 0000000..ca359ed --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/managers/SleepTimerManager.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaEventManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaEventManager.kt new file mode 100644 index 0000000..82bf3f4 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaEventManager.kt @@ -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() + ) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt new file mode 100644 index 0000000..ea5fbdf --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaManager.kt @@ -0,0 +1,1028 @@ +package com.audiobookshelf.app.media + +import android.app.Activity +import android.content.Context +import android.support.v4.media.MediaBrowserCompat +import android.util.Log +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.server.ApiHandler +import com.getcapacitor.JSObject +import java.util.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import org.json.JSONException +import org.json.JSONObject +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import java.util.concurrent.atomic.AtomicInteger + +class MediaManager(private var apiHandler: ApiHandler, var ctx: Context) { + val tag = "MediaManager" + + private var serverLibraryItems = mutableListOf() // Store all items here + + private var cachedLibraryAuthors : MutableMap> = hashMapOf() + private var cachedLibraryAuthorItems : MutableMap>> = hashMapOf() + private var cachedLibraryAuthorSeriesItems : MutableMap>> = hashMapOf() + private var cachedLibrarySeries : MutableMap> = hashMapOf() + private var cachedLibrarySeriesItem : MutableMap>> = hashMapOf() + private var cachedLibraryCollections : MutableMap> = hashMapOf() + private var cachedLibraryRecentShelves : MutableMap> = hashMapOf() + private var cachedLibraryDiscovery : MutableMap> = hashMapOf() + private var cachedLibraryPodcasts : MutableMap> = hashMapOf() + private var isLibraryPodcastsCached : MutableMap = hashMapOf() + var allLibraryPersonalizationsDone : Boolean = false + private var libraryPersonalizationsDone : Int = 0 + + private var selectedPodcast:Podcast? = null + private var selectedLibraryItemId:String? = null + private var podcastEpisodeLibraryItemMap = mutableMapOf() + private var serverConfigIdUsed:String? = null + private var serverConfigLastPing:Long = 0L + var serverUserMediaProgress:MutableList = mutableListOf() + var serverItemsInProgress = listOf() + var serverLibraries = listOf() + + var userSettingsPlaybackRate:Float? = null + + // Android Auto browses on the media browser service main thread, so the server pings + // and authorize call must not run there or they block it and trigger an ANR + private val androidAutoIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + // Android Auto re-requests the browse root every ~2s and multiple clients do so at once, + // so overlapping loads are coalesced into one to avoid racing on the shared server state + private var isLoadingAndroidAutoItems = false + private val pendingAndroidAutoLoadCallbacks = mutableListOf<() -> Unit>() + private val androidAutoLoadLock = Any() + + fun getIsLibrary(id:String) : Boolean { + return serverLibraries.find { it.id == id } != null + } + + /** + * Check if there is discovery shelf for [libraryId] + * If personalized shelves are not yet populated for library then populate + * + */ + fun getHasDiscovery(libraryId: String) : Boolean { + if (cachedLibraryDiscovery.containsKey(libraryId)) { + if (cachedLibraryDiscovery[libraryId]!!.isNotEmpty()) { + return true + } + } else { + populatePersonalizedDataForLibrary(libraryId){} + } + return false + } + + fun getLibrary(id:String) : Library? { + return serverLibraries.find { it.id == id } + } + + /** + * Add [libraryItem] to [serverLibraryItems] if it is not already added + */ + private fun addServerLibrary(libraryItem: LibraryItem) { + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + + fun getSavedPlaybackRate():Float { + if (userSettingsPlaybackRate != null) { + return userSettingsPlaybackRate ?: 1f + } + + val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE) + if (sharedPrefs != null) { + val userSettingsPref = sharedPrefs.getString("userSettings", null) + if (userSettingsPref != null) { + try { + val userSettings = JSObject(userSettingsPref) + if (userSettings.has("playbackRate")) { + userSettingsPlaybackRate = userSettings.getDouble("playbackRate").toFloat() + return userSettingsPlaybackRate ?: 1f + } + } catch(je:JSONException) { + Log.e(tag, "Failed to parse userSettings JSON ${je.localizedMessage}") + } + } + } + return 1f + } + + fun setSavedPlaybackRate(newRate: Float) { + val sharedPrefs = ctx.getSharedPreferences("CapacitorStorage", Activity.MODE_PRIVATE) + val sharedPrefEditor = sharedPrefs.edit() + if (sharedPrefs != null) { + val userSettingsPref = sharedPrefs.getString("userSettings", null) + if (userSettingsPref != null) { + try { + val userSettings = JSObject(userSettingsPref) + // toString().toDouble() to prevent float conversion issues (ex 1.2f becomes 1.2000000476837158d) + userSettings.put("playbackRate", newRate.toString().toDouble()) + sharedPrefEditor.putString("userSettings", userSettings.toString()) + sharedPrefEditor.apply() + userSettingsPlaybackRate = newRate + Log.d(tag, "Saved userSettings JSON from Android Auto with playbackRate=$newRate") + } catch(je:JSONException) { + Log.e(tag, "Failed to save userSettings JSON ${je.localizedMessage}") + } + } else { + // Not sure if this is the best place for this, but if a user has not changed any user settings in the app + // the object will not exist yet, could be moved to a centralized place or created on first app load + val userSettings = JSONObject() + userSettings.put("playbackRate", newRate.toString().toDouble()) + sharedPrefEditor.putString("userSettings", userSettings.toString()) + userSettingsPlaybackRate = newRate + Log.d(tag, "Created and saved userSettings JSON from Android Auto with playbackRate=$newRate") + } + } + } + + fun checkResetServerItems():Boolean { + // When opening android auto need to check if still connected to server + // and reset any server data already set + val serverConnConfig = if (DeviceManager.isConnectedToServer) DeviceManager.serverConnectionConfig else DeviceManager.deviceData.getLastServerConnectionConfig() + + if (!DeviceManager.isConnectedToServer || !DeviceManager.checkConnectivity(ctx) || serverConnConfig == null || serverConnConfig.id != serverConfigIdUsed) { + podcastEpisodeLibraryItemMap = mutableMapOf() + serverLibraries = listOf() + serverLibraryItems = mutableListOf() + cachedLibraryAuthors = hashMapOf() + cachedLibraryAuthorItems = hashMapOf() + cachedLibraryAuthorSeriesItems = hashMapOf() + cachedLibrarySeries = hashMapOf() + cachedLibrarySeriesItem = hashMapOf() + cachedLibraryCollections = hashMapOf() + cachedLibraryRecentShelves = hashMapOf() + cachedLibraryDiscovery = hashMapOf() + cachedLibraryPodcasts = hashMapOf() + isLibraryPodcastsCached = hashMapOf() + serverItemsInProgress = listOf() + allLibraryPersonalizationsDone = false + libraryPersonalizationsDone = 0 + return true + } + return false + } + + private fun loadItemsInProgressForAllLibraries(cb: (List) -> Unit) { + if (serverItemsInProgress.isNotEmpty()) { + cb(serverItemsInProgress) + } else { + apiHandler.getAllItemsInProgress { itemsInProgress -> + serverItemsInProgress = itemsInProgress.filter { + val libraryItem = it.libraryItemWrapper as LibraryItem + libraryItem.checkHasTracks() + } + cb(serverItemsInProgress) + } + } + } + + /** + * Load personalized shelves from server for all libraries. + * [cb] resolves when all libraries are processed + */ + fun populatePersonalizedDataForAllLibraries(cb: () -> Unit) { + val remaining = AtomicInteger(serverLibraries.size) + + serverLibraries.forEach { lib -> + Log.d(tag, "Loading personalization for library ${lib.name}") + populatePersonalizedDataForLibrary(lib.id) { + Log.d(tag, "Loaded personalization for library ${lib.name}") + if (remaining.decrementAndGet() == 0) { + Log.d(tag, "Finished loading all library personalization data") + allLibraryPersonalizationsDone = true + cb() + } + } + } + } + + /** + * Get personalized shelves from server for selected [libraryId]. + * Populates [cachedLibraryRecentShelves] and [cachedLibraryDiscovery]. + */ + private fun populatePersonalizedDataForLibrary(libraryId: String, cb: () -> Unit) { + apiHandler.getLibraryPersonalized(libraryId) { shelves -> + Log.d(tag, "populatePersonalizedDataForLibrary $libraryId") + if (shelves === null) return@getLibraryPersonalized + shelves.map { shelf -> + Log.d(tag, "$shelf") + if (shelf.type == "book") { + if (shelf.id == "continue-listening") return@map + else if (shelf.id == "listen-again") return@map + else if (shelf.id == "recently-added") { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + } + else if (shelf.id == "discover") { + if (!cachedLibraryDiscovery.containsKey(libraryId)) { + cachedLibraryDiscovery[libraryId] = mutableListOf() + } + (shelf as LibraryShelfBookEntity).entities?.map { + cachedLibraryDiscovery[libraryId]!!.add(it) + } + } + else if (shelf.id == "continue-reading") return@map + else if (shelf.id == "continue-series") return@map + shelf as LibraryShelfBookEntity + } else if (shelf.type == "series") { + if (shelf.id == "recent-series") { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + } + } else if (shelf.type == "episode") { + if (shelf.id == "continue-listening") return@map + else if (shelf.id == "listen-again") return@map + else if (shelf.id == "newest-episodes") { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + + val podcastLibraryItemIds = mutableListOf() + (shelf as LibraryShelfEpisodeEntity).entities?.forEach { libraryItem -> + if (!podcastLibraryItemIds.contains(libraryItem.id)) { + podcastLibraryItemIds.add(libraryItem.id) + loadPodcastItem(libraryItem.libraryId, libraryItem.id) {} + } + } + } + } else if (shelf.type == "podcast") { + if (shelf.id == "recently-added"){ + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + } + else if (shelf.id == "discover"){ + return@map + } + } else if (shelf.type =="authors") { + if (shelf.id == "newest-authors") { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cachedLibraryRecentShelves[libraryId] = mutableListOf() + } + if (cachedLibraryRecentShelves[libraryId]?.find { it.id == shelf.id } == null) { + cachedLibraryRecentShelves[libraryId]!!.add(shelf) + } + } + } + + } + Log.d(tag, "populatePersonalizedDataForLibrary $libraryId DONE") + cb() + } + } + + /** + * Returns podcasts for selected library. + * If data is not found from local cache it is loaded from server + */ + fun loadLibraryPodcasts(libraryId:String, cb: (List?) -> Unit) { + // Without this there is possibility that only recent podcasts get loaded + // Loading recent podcasts will also create cachedLibraryPodcasts entry for library + if (!isLibraryPodcastsCached.containsKey(libraryId)) { + isLibraryPodcastsCached[libraryId] = false + } + // Ensure that there is map for library + if (!cachedLibraryPodcasts.containsKey(libraryId)) { + cachedLibraryPodcasts[libraryId] = mutableMapOf() + } + if (isLibraryPodcastsCached.getOrElse(libraryId) {false}) { + Log.d(tag, "loadLibraryPodcasts: Found from cache: $libraryId") + cb(cachedLibraryPodcasts[libraryId]?.values?.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title }) + } else { + apiHandler.getLibraryItems(libraryId) { libraryItems -> + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + + libraryItemsWithAudio.forEach { libraryItem -> + cachedLibraryPodcasts[libraryId]?.set(libraryItem.id, libraryItem) + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + isLibraryPodcastsCached[libraryId] = true + Log.d(tag, "loadLibraryPodcasts: loaded from server: $libraryId") + cb(libraryItemsWithAudio.sortedBy { libraryItem -> (libraryItem.media as Podcast).metadata.title }) + } + } + } + + /** + * Returns series with audio books from selected library. + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibrarySeriesWithAudio(libraryId:String, cb: (List) -> Unit) { + // Check "cache" first + if (cachedLibrarySeries.containsKey(libraryId)) { + Log.d(tag, "Series with audio found from cache | Library $libraryId ") + cb(cachedLibrarySeries[libraryId] as List) + } else { + apiHandler.getLibrarySeries(libraryId) { seriesItems -> + Log.d(tag, "Series with audio loaded from server | Library $libraryId") + val seriesItemsWithAudio = seriesItems.filter { si -> si.audiobookCount > 0 } + + cachedLibrarySeries[libraryId] = seriesItemsWithAudio + + cb(seriesItemsWithAudio) + } + } + } + + /** + * Returns series with audiobooks from selected library using filter for paging. + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibrarySeriesWithAudio(libraryId:String, seriesFilter:String, cb: (List) -> Unit) { + // Check "cache" first + if (!cachedLibrarySeries.containsKey(libraryId)) { + loadLibrarySeriesWithAudio(libraryId) {} + } else { + Log.d(tag, "Series with audio found from cache | Library $libraryId ") + } + val seriesWithBooks = cachedLibrarySeries[libraryId]!!.filter { ls -> ls.title.uppercase().startsWith(seriesFilter) }.toList() + cb(seriesWithBooks) + } + + /** + * Sorts books in series. Assumes that sequence is main.minor + */ + private fun sortSeriesBooks(seriesBooks: List) : List { + val sortingLogic = compareBy { it.seriesSequenceParts[0].length } + .thenBy { it.seriesSequenceParts[0].ifEmpty { "" } } + .thenBy { it.seriesSequenceParts.getOrElse(1) { "" }.length } + .thenBy { it.seriesSequenceParts.getOrElse(1) { "" } } + return seriesBooks.sortedWith(sortingLogic) + } + + /** + * Returns books for series from library. + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibrarySeriesItemsWithAudio(libraryId:String, seriesId:String, cb: (List) -> Unit) { + // Check "cache" first + if (!cachedLibrarySeriesItem.containsKey(libraryId)) { + cachedLibrarySeriesItem[libraryId] = hashMapOf() + } + if (cachedLibrarySeriesItem[libraryId]!!.containsKey(seriesId)) { + Log.d(tag, "Items for series $seriesId found from cache | Library $libraryId") + cachedLibrarySeriesItem[libraryId]!![seriesId]?.let { cb(it) } + } else { + apiHandler.getLibrarySeriesItems(libraryId, seriesId) { libraryItems -> + Log.d(tag, "Items for series $seriesId loaded from server | Library $libraryId") + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + + val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsWithAudio) + cachedLibrarySeriesItem[libraryId]!![seriesId] = sortedLibraryItemsWithAudio + + sortedLibraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + cb(sortedLibraryItemsWithAudio) + } + } + } + + /** + * Returns authors with books from library. + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorsWithBooks(libraryId:String, cb: (List) -> Unit) { + // Check "cache" first + if (cachedLibraryAuthors.containsKey(libraryId)) { + Log.d(tag, "Authors with books found from cache | Library $libraryId ") + cb(cachedLibraryAuthors[libraryId]!!.values.toList()) + } else { + // Fetch data from server and add it to local "cache" + apiHandler.getLibraryAuthors(libraryId) { authorItems -> + Log.d(tag, "Authors with books loaded from server | Library $libraryId ") + // TO-DO: This check won't ensure that there is audiobooks. Current API won't offer ability to do so + var authorItemsWithBooks = authorItems.filter { li -> li.bookCount != null && li.bookCount!! > 0 } + authorItemsWithBooks = authorItemsWithBooks.sortedBy { it.name } + // Ensure that there is map for library + cachedLibraryAuthors[libraryId] = mutableMapOf() + // Cache authors + authorItemsWithBooks.forEach { + if (!cachedLibraryAuthors[libraryId]!!.containsKey(it.id)) { + cachedLibraryAuthors[libraryId]!![it.id] = it + } + } + cb(authorItemsWithBooks) + } + } + } + + /** + * Returns authors with books from selected library using filter for paging. + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorsWithBooks(libraryId:String, authorFilter: String, cb: (List) -> Unit) { + // Check "cache" first + if (cachedLibraryAuthors.containsKey(libraryId)) { + Log.d(tag, "Authors with books found from cache | Library $libraryId ") + } else { + loadAuthorsWithBooks(libraryId) {} + } + val authorsWithBooks = cachedLibraryAuthors[libraryId]!!.values.filter { lai -> lai.name.uppercase().startsWith(authorFilter) }.toList() + cb(authorsWithBooks) + } + + /** + * Returns audiobooks for author from library + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorBooksWithAudio(libraryId:String, authorId:String, cb: (List) -> Unit) { + // Ensure that there is map for library + if (!cachedLibraryAuthorItems.containsKey(libraryId)) { + cachedLibraryAuthorItems[libraryId] = mutableMapOf() + } + // Check "cache" first + if (cachedLibraryAuthorItems[libraryId]!!.containsKey(authorId)) { + Log.d(tag, "Items for author $authorId found from cache | Library $libraryId") + cachedLibraryAuthorItems[libraryId]!![authorId]?.let { cb(it) } + } else { + apiHandler.getLibraryItemsFromAuthor(libraryId, authorId) { libraryItems -> + Log.d(tag, "Items for author $authorId loaded from server | Library $libraryId") + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + + cachedLibraryAuthorItems[libraryId]!![authorId] = libraryItemsWithAudio + + libraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + + cb(libraryItemsWithAudio) + } + } + } + + /** + * Returns audiobooks for author from specified series within library + * If data is not found from local cache then it will be fetched from server + */ + fun loadAuthorSeriesBooksWithAudio(libraryId:String, authorId:String, seriesId: String, cb: (List) -> Unit) { + val authorSeriesKey = "$authorId|$seriesId" + // Ensure that there is map for library + if (!cachedLibraryAuthorSeriesItems.containsKey(libraryId)) { + cachedLibraryAuthorSeriesItems[libraryId] = mutableMapOf() + } + // Check "cache" first + if (cachedLibraryAuthorSeriesItems[libraryId]!!.containsKey(authorSeriesKey)) { + Log.d(tag, "Items for series $seriesId with author $authorId found from cache | Library $libraryId") + cachedLibraryAuthorSeriesItems[libraryId]!![authorSeriesKey]?.let { cb(it) } + } else { + apiHandler.getLibrarySeriesItems(libraryId, seriesId) { libraryItems -> + Log.d(tag, "Items for series $seriesId with author $authorId loaded from server | Library $libraryId") + val libraryItemsWithAudio = libraryItems.filter { li -> li.checkHasTracks() } + if (!cachedLibraryAuthors[libraryId]!!.containsKey(authorId)) { + Log.d(tag, "Author data is missing") + } + val authorName = cachedLibraryAuthors[libraryId]!![authorId]?.name ?: "" + Log.d(tag, "Using author name: $authorName") + val libraryItemsFromAuthorWithAudio = libraryItemsWithAudio.filter { li -> li.authorName.indexOf(authorName, ignoreCase = true) >= 0 } + + val sortedLibraryItemsWithAudio = sortSeriesBooks(libraryItemsFromAuthorWithAudio) + cachedLibraryAuthorSeriesItems[libraryId]!![authorId] = sortedLibraryItemsWithAudio + + sortedLibraryItemsWithAudio.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + + cb(sortedLibraryItemsWithAudio) + } + } + } + + /** + * Returns collections with audiobooks from library + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibraryCollectionsWithAudio(libraryId:String, cb: (List) -> Unit) { + if (cachedLibraryCollections.containsKey(libraryId)) { + Log.d(tag, "Collections with books found from cache | Library $libraryId ") + cb(cachedLibraryCollections[libraryId]!!.values.toList()) + } else { + apiHandler.getLibraryCollections(libraryId) { libraryCollections -> + Log.d(tag, "Collections with books loaded from server | Library $libraryId ") + val libraryCollectionsWithAudio = libraryCollections.filter { lc -> lc.audiobookCount > 0 } + + // Cache collections + cachedLibraryCollections[libraryId] = hashMapOf() + libraryCollectionsWithAudio.forEach { + if (!cachedLibraryCollections[libraryId]!!.containsKey(it.id)) { + cachedLibraryCollections[libraryId]!![it.id] = it + } + } + cb(libraryCollectionsWithAudio) + } + } + } + + /** + * Returns audiobooks for collection from library + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibraryCollectionBooksWithAudio(libraryId: String, collectionId: String, cb: (List) -> Unit) { + if (!cachedLibraryCollections.containsKey(libraryId)) { + loadLibraryCollectionsWithAudio(libraryId) {} + } + Log.d(tag, "Trying to find collection $collectionId items from from cache | Library $libraryId ") + if ( cachedLibraryCollections[libraryId]!!.containsKey(collectionId)) { + val libraryCollectionBookswithAudio = cachedLibraryCollections[libraryId]!![collectionId]?.books + libraryCollectionBookswithAudio?.forEach { libraryItem -> + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + } + cb(libraryCollectionBookswithAudio as List) + } + } + + /** + * Returns audiobooks from discovery shelf for [libraryId] + * If data is not found from local cache then it will be fetched from server + */ + fun loadLibraryDiscoveryBooksWithAudio(libraryId: String, cb: (List) -> Unit) { + if (!cachedLibraryDiscovery.containsKey(libraryId)) { + cb(listOf()) + } + val libraryItemsWithAudio = cachedLibraryDiscovery[libraryId]?.filter { li -> li.checkHasTracks() } + libraryItemsWithAudio?.forEach { libraryItem -> addServerLibrary(libraryItem) } + cb(libraryItemsWithAudio as List) + } + + /** + * Returns recent shelves for [libraryId] + * If data is not shelves are found returns empty list + */ + fun getLibraryRecentShelfs(libraryId: String, cb: (List) -> Unit) { + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + Log.d(tag, "getLibraryRecentShelfs: No shelves $libraryId") + cb(listOf()) + return + } + cb(cachedLibraryRecentShelves[libraryId] as List) + } + + /** + * Returns recent shelf by [type] for [libraryId] + * If shelf is not found returns null + */ + fun getLibraryRecentShelfByType(libraryId: String, type:String, cb: (LibraryShelfType?) -> Unit) { + Log.d(tag, "getLibraryRecentShelfByType: $libraryId | $type") + if (!cachedLibraryRecentShelves.containsKey(libraryId)) { + cb(null) + return + } + for (shelf in cachedLibraryRecentShelves[libraryId]!!) { + if (shelf.type == type.lowercase()) { + cb(shelf) + return + } + } + cb(null) + } + + /** + * Loads podcasts for newest episodes shelf + */ + private fun loadPodcastItem(libraryId: String, libraryItemId: String, cb: (LibraryItem?) -> Unit) { + // Ensure that there is map for library + if (!cachedLibraryPodcasts.containsKey(libraryId)) { + cachedLibraryPodcasts[libraryId] = mutableMapOf() + } + if (cachedLibraryPodcasts[libraryId]!!.containsKey(libraryItemId)) { + Log.d(tag, "loadPodcastItem: Podcast found from cache | Library $libraryItemId ") + cb(cachedLibraryPodcasts[libraryId]?.get(libraryItemId)) + } else { + Log.d(tag, "loadPodcastItem: Calling getLibraryItem $libraryItemId") + apiHandler.getLibraryItem(libraryItemId) { libraryItem -> + if (libraryItem !== null) { + Log.d(tag, "loadPodcastItem: Got library item ${libraryItem.id} ${libraryItem.media.metadata.title}") + val podcast = libraryItem.media as Podcast + podcast.episodes?.forEach { podcastEpisode -> + podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItem, podcastEpisode) + } + cachedLibraryPodcasts[libraryId]?.set(libraryItemId, libraryItem) + cb(libraryItem) + } + } + } + } + + private fun loadLibraryItem(libraryItemId:String, cb: (LibraryItemWrapper?) -> Unit) { + if (libraryItemId.startsWith("local")) { + cb(DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)) + } else { + Log.d(tag, "loadLibraryItem: $libraryItemId") + apiHandler.getLibraryItem(libraryItemId) { libraryItem -> + Log.d(tag, "loadLibraryItem: Got library item $libraryItem") + cb(libraryItem) + } + } + } + + fun loadPodcastEpisodeMediaBrowserItems(libraryItemId:String, ctx:Context, cb: (MutableList) -> Unit) { + loadLibraryItem(libraryItemId) { libraryItemWrapper -> + Log.d(tag, "Loaded Podcast library item $libraryItemWrapper") + + libraryItemWrapper?.let { + if (libraryItemWrapper is LocalLibraryItem) { // Local podcast episodes + if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { + cb(mutableListOf()) + } else { + val podcast = libraryItemWrapper.media as Podcast + selectedLibraryItemId = libraryItemWrapper.id + selectedPodcast = podcast + + val children = podcast.episodes?.map { podcastEpisode -> + Log.d(tag, "Local Podcast Episode ${podcastEpisode.title} | ${podcastEpisode.id}") + + val progress = DeviceManager.dbManager.getLocalMediaProgress("${libraryItemWrapper.id}-${podcastEpisode.id}") + val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx) + + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) + } + } else if (libraryItemWrapper is LibraryItem) { // Server podcast episodes + if (libraryItemWrapper.mediaType != "podcast" || libraryItemWrapper.media.getAudioTracks().isEmpty()) { + cb(mutableListOf()) + } else { + val podcast = libraryItemWrapper.media as Podcast + podcast.episodes?.forEach { podcastEpisode -> + podcastEpisodeLibraryItemMap[podcastEpisode.id] = LibraryItemWithEpisode(libraryItemWrapper, podcastEpisode) + } + selectedLibraryItemId = libraryItemWrapper.id + selectedPodcast = podcast + val episodes = podcast.episodes?.sortedByDescending { it.publishedAt } + val children = episodes?.map { podcastEpisode -> + + val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItemWrapper.id && it.episodeId == podcastEpisode.id } + + // to show download icon + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemWrapper.id) + localLibraryItem?.let { lli -> + val localEpisode = (lli.media as Podcast).episodes?.find { it.serverEpisodeId == podcastEpisode.id } + podcastEpisode.localEpisodeId = localEpisode?.id + } + + val description = podcastEpisode.getMediaDescription(libraryItemWrapper, progress, ctx) + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + children?.let { cb(children as MutableList) } ?: cb(mutableListOf()) + } + } + } + } + } + + /** + * Loads libraries for selected server with stats + */ + private fun loadLibraries(cb: (List) -> Unit) { + if (serverLibraries.isNotEmpty()) { + cb(serverLibraries) + } else { + apiHandler.getLibraries { loadedLibraries -> + serverLibraries = loadedLibraries + cb(serverLibraries) + } + } + } + + private suspend fun checkServerConnection(config:ServerConnectionConfig) : Boolean { + var successfulPing = false + suspendCoroutine { cont -> + apiHandler.pingServer(config) { + Log.d(tag, "checkServerConnection: Checked server conn for ${config.address} result = $it") + successfulPing = it + cont.resume(it) + } + } + return successfulPing + } + + private suspend fun authorize(config:ServerConnectionConfig) : MutableList { + var mediaProgress:MutableList = mutableListOf() + suspendCoroutine { cont -> + apiHandler.authorize(config) { + Log.d(tag, "authorize: Authorized server config ${config.address} result = $it") + if (!it.isNullOrEmpty()) { + mediaProgress = it + } + cont.resume(mediaProgress) + } + } + return mediaProgress + } + + // Runs on [androidAutoIOScope] rather than blocking the caller: this is reached from + // onLoadChildren on the media browser service main thread, and the pings/authorize below + // are network calls, so runBlocking here caused ANRs while browsing in Android Auto + private fun checkSetValidServerConnectionConfig(cb: (Boolean) -> Unit) = androidAutoIOScope.launch { + Log.d(tag, "checkSetValidServerConnectionConfig | serverConfigIdUsed=$serverConfigIdUsed | lastServerConnectionConfigId=${DeviceManager.deviceData.lastServerConnectionConfigId}") + + coroutineScope { + if (!DeviceManager.checkConnectivity(ctx)) { + serverUserMediaProgress = mutableListOf() + Log.d(tag, "checkSetValidServerConnectionConfig: No connectivity") + cb(false) + } else if (DeviceManager.deviceData.lastServerConnectionConfigId.isNullOrBlank()) { // If in offline mode last server connection config is unset + serverUserMediaProgress = mutableListOf() + Log.d(tag, "checkSetValidServerConnectionConfig: No last server connection config") + cb(false) + } else { + var hasValidConn = false + var lookupMediaProgress = true + + if (!serverConfigIdUsed.isNullOrEmpty() && serverConfigLastPing > 0L && System.currentTimeMillis() - serverConfigLastPing < 5000) { + Log.d(tag, "checkSetValidServerConnectionConfig last ping less than a 5 seconds ago") + hasValidConn = true + lookupMediaProgress = false + } else { + serverUserMediaProgress = mutableListOf() + } + + if (!hasValidConn) { + // First check if the current selected config is pingable + DeviceManager.serverConnectionConfig?.let { + hasValidConn = checkServerConnection(it) + Log.d( + tag, + "checkSetValidServerConnectionConfig: Current config ${DeviceManager.serverAddress} is pingable? $hasValidConn" + ) + } + } + + if (!hasValidConn) { + // Loop through available configs and check if can connect + for (config: ServerConnectionConfig in DeviceManager.deviceData.serverConnectionConfigs) { + val result = checkServerConnection(config) + + if (result) { + hasValidConn = true + DeviceManager.serverConnectionConfig = config + Log.d(tag, "checkSetValidServerConnectionConfig: Set server connection config ${DeviceManager.serverConnectionConfigId}") + break + } + } + } + + if (hasValidConn) { + serverConfigLastPing = System.currentTimeMillis() + + if (lookupMediaProgress) { + Log.d(tag, "Has valid conn now get user media progress") + DeviceManager.serverConnectionConfig?.let { + serverUserMediaProgress = authorize(it) + } + } + } + + cb(hasValidConn) + } + } + + } + + fun loadServerUserMediaProgress(cb: () -> Unit) { + Log.d(tag, "Loading server media progress") + if (DeviceManager.serverConnectionConfig == null) { + return cb() + } + + DeviceManager.serverConnectionConfig?.let { config -> + apiHandler.authorize(config) { + Log.d(tag, "loadServerUserMediaProgress: Authorized server config ${config.address} result = $it") + if (!it.isNullOrEmpty()) { + serverUserMediaProgress = it + } + cb() + } + } + } + + fun initializeInProgressItems(cb: () -> Unit) { + Log.d(tag, "Initializing inprogress items") + + loadItemsInProgressForAllLibraries { itemsInProgress -> + itemsInProgress.forEach { + val libraryItem = it.libraryItemWrapper as LibraryItem + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + + if (it.episode != null) { + podcastEpisodeLibraryItemMap[it.episode.id] = LibraryItemWithEpisode(it.libraryItemWrapper, it.episode) + } + } + Log.d(tag, "Initializing inprogress items done") + cb() + } + } + + fun loadAndroidAutoItems(cb: () -> Unit) { + Log.d(tag, "Load android auto items") + + // Coalesce overlapping calls into a single load, every caller still gets its callback + synchronized(androidAutoLoadLock) { + pendingAndroidAutoLoadCallbacks.add(cb) + if (isLoadingAndroidAutoItems) { + Log.d(tag, "loadAndroidAutoItems: Load already in progress, queued callback") + return + } + isLoadingAndroidAutoItems = true + } + + val onLoadFinished = { + val callbacks: List<() -> Unit> + synchronized(androidAutoLoadLock) { + isLoadingAndroidAutoItems = false + callbacks = pendingAndroidAutoLoadCallbacks.toList() + pendingAndroidAutoLoadCallbacks.clear() + } + callbacks.forEach { it() } + } + + // Check if any valid server connection if not use locally downloaded books + checkSetValidServerConnectionConfig { isConnected -> + if (isConnected) { + serverConfigIdUsed = DeviceManager.serverConnectionConfigId + Log.d(tag, "loadAndroidAutoItems: Connected to server config id=$serverConfigIdUsed") + + loadLibraries { libraries -> + if (libraries.isEmpty()) { + Log.w(tag, "No libraries returned from server request") + onLoadFinished() + } else { + onLoadFinished() // Fully loaded + } + } + } else { // Not connected to server + Log.d(tag, "loadAndroidAutoItems: Not connected to server") + onLoadFinished() + } + } + } + + /** + * Handles search requests. + * Searches from books, series and authors + */ + suspend fun doSearch(libraryId: String, queryString: String) : Map> { + return suspendCoroutine { + apiHandler.getSearchResults(libraryId, queryString) { searchResult -> + Log.d(tag, "searchLocalCache: $searchResult") + // Nothing found from server + if (searchResult === null) { + it.resume(mapOf()) + return@getSearchResults + } + + val foundItems: MutableMap> = mutableMapOf() + + val serverLibrary = serverLibraries.find { sl -> sl.id == libraryId } + + // Books + if (searchResult.book !== null && searchResult.book!!.isNotEmpty()) { + Log.d(tag, "searchLocalCache: found ${searchResult.book!!.size} books") + val children = searchResult.book!!.filter { it.libraryItem.checkHasTracks() }.map { bookResult -> + val libraryItem = bookResult.libraryItem + + if (serverLibraryItems.find { li -> li.id == libraryItem.id } == null) { + serverLibraryItems.add(libraryItem) + } + val progress = serverUserMediaProgress.find { it.libraryItemId == libraryItem.id } + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, null, "Books (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_PLAYABLE) + } + foundItems["book"] = children + } + if (searchResult.series !== null && searchResult.series!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.series!!.size} series") + val children = searchResult.series!!.map { seriesResult -> + val seriesItem = seriesResult.series + seriesItem.books = seriesResult.books as MutableList + val description = seriesItem.getMediaDescription(null, ctx, "Series (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + foundItems["series"] = children + } + if (searchResult.authors !== null && searchResult.authors!!.isNotEmpty()) { + Log.d(tag, "onSearch: found ${searchResult.authors!!.size} authors") + val children = searchResult.authors!!.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx, "Authors (${serverLibrary?.name})") + MediaBrowserCompat.MediaItem(description, MediaBrowserCompat.MediaItem.FLAG_BROWSABLE) + } + foundItems["authors"] = children + } + + it.resume(foundItems) + } + } + } + + fun getFirstItem() : LibraryItemWrapper? { + if (serverLibraryItems.isNotEmpty()) { + return serverLibraryItems[0] + } else { + val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") + return if (localBooks.isNotEmpty()) return localBooks[0] else null + } + } + + fun getPodcastWithEpisodeByEpisodeId(id:String) : LibraryItemWithEpisode? { + return if (id.startsWith("local")) { + DeviceManager.dbManager.getLocalLibraryItemWithEpisode(id) + } else { + podcastEpisodeLibraryItemMap[id] + } + } + + fun getById(id:String) : LibraryItemWrapper? { + return if (id.startsWith("local")) { + DeviceManager.dbManager.getLocalLibraryItem(id) + } else { + serverLibraryItems.find { it.id == id } + } + } + + fun getFromSearch(query:String?) : LibraryItemWrapper? { + if (query.isNullOrEmpty()) return getFirstItem() + return serverLibraryItems.find { + it.title.lowercase(Locale.getDefault()).contains(query.lowercase(Locale.getDefault())) + } + } + + fun play(libraryItemWrapper:LibraryItemWrapper, episode:PodcastEpisode?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) { + if (libraryItemWrapper is LocalLibraryItem) { + cb(libraryItemWrapper.getPlaybackSession(episode, playItemRequestPayload.deviceInfo)) + } else { + val libraryItem = libraryItemWrapper as LibraryItem + apiHandler.playLibraryItem(libraryItem.id,episode?.id ?: "", playItemRequestPayload) { + if (it == null) { + cb(null) + } else { + cb(it) + } + } + } + } + + private fun levenshtein(lhs : CharSequence, rhs : CharSequence) : Int { + val lhsLength = lhs.length + 1 + val rhsLength = rhs.length + 1 + + var cost = Array(lhsLength) { it } + var newCost = Array(lhsLength) { 0 } + + for (i in 1..rhsLength-1) { + newCost[0] = i + + for (j in 1..lhsLength-1) { + val match = if(lhs[j - 1] == rhs[i - 1]) 0 else 1 + + val costReplace = cost[j - 1] + match + val costInsert = cost[j] + 1 + val costDelete = newCost[j - 1] + 1 + + newCost[j] = Math.min(Math.min(costInsert, costDelete), costReplace) + } + + val swap = cost + cost = newCost + newCost = swap + } + + return cost[lhsLength - 1] + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/MediaProgressSyncer.kt b/android/app/src/main/java/com/audiobookshelf/app/media/MediaProgressSyncer.kt new file mode 100644 index 0000000..1aeed0a --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/MediaProgressSyncer.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/media/icons.kt b/android/app/src/main/java/com/audiobookshelf/app/media/icons.kt new file mode 100644 index 0000000..0cfaa94 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/media/icons.kt @@ -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)) +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt new file mode 100644 index 0000000..cfab08d --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItem.kt @@ -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, + @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 { + val itemParts = mutableListOf() + 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 + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt new file mode 100644 index 0000000..a1e60f7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/models/DownloadItemPart.kt @@ -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-") + +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/models/User.kt b/android/app/src/main/java/com/audiobookshelf/app/models/User.kt new file mode 100644 index 0000000..6d610bf --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/models/User.kt @@ -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 +) diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt b/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt new file mode 100644 index 0000000..3b2d9c4 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/AbMediaDescriptionAdapter.kt @@ -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 + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt new file mode 100644 index 0000000..8dd5cd1 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/BrowseTree.kt @@ -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, + libraries: List, + recentsLoaded: Boolean +) { + private val mediaIdToChildren = mutableMapOf>() + + 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__" diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt new file mode 100644 index 0000000..dd177d8 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastManager.kt @@ -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 { + 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?) { + 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?) + + /** 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 = 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 + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt new file mode 100644 index 0000000..56687a8 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastPlayer.kt @@ -0,0 +1,1028 @@ +package com.audiobookshelf.app.player + +import android.annotation.SuppressLint +import android.os.Looper +import android.util.Log +import kotlin.math.roundToInt +import android.view.Surface +import android.view.SurfaceHolder +import android.view.SurfaceView +import android.view.TextureView +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.C.TrackType +import com.google.android.exoplayer2.MediaMetadata +import com.google.android.exoplayer2.Player.* +import com.google.android.exoplayer2.Tracks.Group +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.ext.cast.SessionAvailabilityListener +import com.google.android.exoplayer2.source.TrackGroup +import com.google.android.exoplayer2.source.TrackGroupArray +import com.google.android.exoplayer2.text.CueGroup +import com.google.android.exoplayer2.trackselection.TrackSelection +import com.google.android.exoplayer2.trackselection.TrackSelectionArray +import com.google.android.exoplayer2.trackselection.TrackSelectionParameters +import com.google.android.exoplayer2.util.* +import com.google.android.exoplayer2.util.Util.castNonNull +import com.google.android.exoplayer2.video.VideoSize +import com.google.android.gms.cast.* +import com.google.android.gms.cast.framework.CastContext +import com.google.android.gms.cast.framework.CastSession +import com.google.android.gms.cast.framework.SessionManagerListener +import com.google.android.gms.cast.framework.media.RemoteMediaClient +import com.google.android.gms.common.api.PendingResult +import com.google.android.gms.common.api.ResultCallback +import org.json.JSONObject + + +class CastPlayer(var castContext: CastContext) : BasePlayer() { + val tag = "CastPlayer" + + private val RENDERER_COUNT = 3 + private val RENDERER_INDEX_VIDEO = 0 + private val RENDERER_INDEX_AUDIO = 1 + private val RENDERER_INDEX_TEXT = 2 + private val PROGRESS_REPORT_PERIOD_MS: Long = 1000 + private val EMPTY_TRACK_SELECTION_ARRAY = TrackSelectionArray() + private val EMPTY_TRACK_ID_ARRAY = LongArray(0) + + private val seekBackIncrementMs: Long = 5000L + private val seekForwardIncrementMs: Long = 5000L + private var myPlayWhenReady:Boolean = false + private var playbackParameters:PlaybackParameters = PlaybackParameters.DEFAULT + private var myAvailableCommands: Commands + private var myCurrentTracksInfo: Tracks + private var myCurrentTrackGroups: TrackGroupArray + private var myCurrentTrackSelections: TrackSelectionArray + + // Cache for pending volume to handle async setVolume + private var pendingVolume: Int? = null + private var pendingVolumeSetAt: Long = 0L + + var currentMediaItems:List = mutableListOf() + var remoteMediaClient:RemoteMediaClient? = null + var sessionAvailabilityListener:SessionAvailabilityListener? = null + var myCurrentTimeline: CastTimeline + val timelineTracker: CastTimelineTracker + val period: Timeline.Period + var listeners: ListenerSet + + /* package */ + val PERMANENT_AVAILABLE_COMMANDS = Commands.Builder() + .addAll( + COMMAND_PLAY_PAUSE, + COMMAND_PREPARE, + COMMAND_STOP, + COMMAND_SEEK_TO_DEFAULT_POSITION, + COMMAND_SEEK_TO_MEDIA_ITEM, + COMMAND_SET_REPEAT_MODE, + COMMAND_SET_SPEED_AND_PITCH, + COMMAND_GET_CURRENT_MEDIA_ITEM, + COMMAND_GET_TIMELINE, + COMMAND_GET_MEDIA_ITEMS_METADATA, + COMMAND_SET_MEDIA_ITEMS_METADATA, + COMMAND_CHANGE_MEDIA_ITEMS, + COMMAND_GET_TRACKS, + COMMAND_GET_DEVICE_VOLUME, + COMMAND_SET_DEVICE_VOLUME, + COMMAND_ADJUST_DEVICE_VOLUME) + .build() + + var currentPlaybackState = Player.STATE_IDLE + var statusListener:StatusListener + + val deviceInfo get() = castContext.sessionManager.currentCastSession?.castDevice.toString() + var lastReportedPositionMs = 0L + + private var currentMediaItemIndex = 0 + private var pendingMediaItemRemovalPosition:PositionInfo? = null + private var pendingSeekCount = 0 + private var pendingSeekWindowIndex = C.INDEX_UNSET + private var pendingSeekPositionMs = 0L + + init { + Log.d(tag, "Init CastPlayer") + myCurrentTrackGroups = TrackGroupArray.EMPTY + myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY + myCurrentTracksInfo = Tracks.EMPTY + statusListener = StatusListener() + timelineTracker = CastTimelineTracker() + myCurrentTimeline = CastTimeline.EMPTY_CAST_TIMELINE + period = Timeline.Period() + myAvailableCommands = Commands.Builder().addAll(PERMANENT_AVAILABLE_COMMANDS).build() + + listeners = ListenerSet( + Looper.getMainLooper(), + Clock.DEFAULT + ) { listener: Player.Listener, flags: FlagSet? -> listener.onEvents(this, Player.Events(flags ?: FlagSet.Builder().build())) } + + val sessionManager = castContext.sessionManager + sessionManager.addSessionManagerListener(statusListener, CastSession::class.java) + val session = sessionManager.currentCastSession + setRemoteMediaClient(session?.remoteMediaClient) + } + + fun load(mediaItems:List, startIndex:Int, startTime:Long, playWhenReady:Boolean, playbackRate:Float, mediaType:String) { + Log.d(tag, "Load called") + + if (remoteMediaClient == null) { + Log.d(tag, "Remote Media Client not set") + return + } + + currentMediaItems = mediaItems + + var mediaQueueItems = mediaItems.map { toMediaQueueItem(it) } + + var queueData = MediaQueueData.Builder().apply { + setItems(mediaQueueItems) + setQueueType(if (mediaType == "book") MediaQueueData.MEDIA_QUEUE_TYPE_AUDIO_BOOK else MediaQueueData.MEDIA_QUEUE_TYPE_PODCAST_SERIES) + setRepeatMode(MediaStatus.REPEAT_MODE_REPEAT_OFF) + setStartIndex(startIndex) + setStartTime(startTime) + }.build() + + var loadRequestData = MediaLoadRequestData.Builder().apply { + setPlaybackRate(playbackRate.toDouble()) + setQueueData(queueData) + setAutoplay(playWhenReady) + setCurrentTime(startTime) + }.build() + + remoteMediaClient?.load(loadRequestData)?.setResultCallback { + Log.d(tag, "Loaded cast player result ${it.status} | ${it.mediaError} | ${it.customData}") + } + + Log.d(tag, "Loaded cast player request data $loadRequestData") + } + + private fun toMediaQueueItem(mediaItem: MediaItem): MediaQueueItem { + // The MediaQueueItem you build is expected to be in the tag. + return (mediaItem.localConfiguration!!.tag as MediaQueueItem?)!! + } + + @JvmName("setRemoteMediaClient1") + private fun setRemoteMediaClient(remoteMediaClient: RemoteMediaClient?) { + if (this.remoteMediaClient === remoteMediaClient) { + // Do nothing. + return + } + if (this.remoteMediaClient != null) { + this.remoteMediaClient?.unregisterCallback(statusListener) + this.remoteMediaClient?.removeProgressListener(statusListener) + } + this.remoteMediaClient = remoteMediaClient + if (remoteMediaClient != null) { + sessionAvailabilityListener?.let { + it.onCastSessionAvailable() + } + + remoteMediaClient.registerCallback(statusListener) + remoteMediaClient.addProgressListener(statusListener, PROGRESS_REPORT_PERIOD_MS) + updateInternalStateAndNotifyIfChanged() + } else { + updateTimelineAndNotifyIfChanged() + sessionAvailabilityListener?.let { + it.onCastSessionUnavailable() + } + } + } + + private fun setPlayerStateAndNotifyIfChanged( + playWhenReady: Boolean, + @PlayWhenReadyChangeReason playWhenReadyChangeReason: Int, + @Player.State playbackState: Int) { + + val wasPlaying = this.currentPlaybackState == STATE_READY && this.playWhenReady + val playWhenReadyChanged = this.playWhenReady != playWhenReady + val playbackStateChanged = this.currentPlaybackState != playbackState + + Log.d(tag, "setPlayerStateAndNotifyIfChanged newPlayWhenReady:$playWhenReady | playbackStateChanged:$playbackStateChanged | playWhenReadyChanged:$playWhenReadyChanged") + if (playWhenReadyChanged || playbackStateChanged) { + this.currentPlaybackState = playbackState + this.myPlayWhenReady = playWhenReady + + if (playbackStateChanged) { + Log.d(tag, "CastPlayer About to emit onPlaybackStateChanged") + listeners.queueEvent(EVENT_PLAYBACK_STATE_CHANGED) { + it.onPlaybackStateChanged(playbackState) + } + } + + if (playWhenReadyChanged) { + listeners.queueEvent(EVENT_PLAY_WHEN_READY_CHANGED) { + it.onPlayWhenReadyChanged(playWhenReady, playWhenReadyChangeReason) + } + } + + val isPlaying = playbackState == STATE_READY && playWhenReady + if (wasPlaying != isPlaying) { + Log.d(tag, "CastPlayer About to emit onIsPlayingChanged $isPlaying") + listeners.queueEvent(EVENT_IS_PLAYING_CHANGED) { + it.onIsPlayingChanged(isPlaying) + } + } else { + Log.d(tag, "Is playing and was playing are equal $wasPlaying") + } + } else { + Log.d(tag, "setPlayerStateAndNotifyIfChanged No changes") + } + listeners.flushEvents() + } + + private fun updatePlayerStateAndNotifyIfChanged() { + var newPlayWhenReadyValue = remoteMediaClient?.isPaused != true + + @PlayWhenReadyChangeReason val playWhenReadyChangeReason = if (newPlayWhenReadyValue != playWhenReady) PLAY_WHEN_READY_CHANGE_REASON_REMOTE else PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST + // We do not mask the playback state, so try setting it regardless of the playWhenReady masking. + setPlayerStateAndNotifyIfChanged( + newPlayWhenReadyValue, playWhenReadyChangeReason, fetchPlaybackState(remoteMediaClient)) + } + + private fun fetchPlaybackState(remoteMediaClient: RemoteMediaClient?): Int { + return when (remoteMediaClient?.playerState) { + MediaStatus.PLAYER_STATE_BUFFERING -> STATE_BUFFERING + MediaStatus.PLAYER_STATE_PLAYING, MediaStatus.PLAYER_STATE_PAUSED -> STATE_READY + MediaStatus.PLAYER_STATE_IDLE, MediaStatus.PLAYER_STATE_UNKNOWN -> STATE_IDLE + else -> STATE_IDLE + } + } + + private fun updatePlaybackRateAndNotifyIfChanged() { + val mediaStatus = remoteMediaClient!!.mediaStatus + val speed = mediaStatus?.playbackRate?.toFloat() ?: PlaybackParameters.DEFAULT.speed + if (speed > 0.0f) { + // Set the speed if not paused. + setPlaybackParametersAndNotifyIfChanged(PlaybackParameters(speed)) + } + } + + private fun setPlaybackParametersAndNotifyIfChanged(playbackParameters: PlaybackParameters) { + if (this.playbackParameters == playbackParameters) { + return + } + this.playbackParameters = playbackParameters + listeners.queueEvent( + EVENT_PLAYBACK_PARAMETERS_CHANGED + ) { listener -> listener.onPlaybackParametersChanged(playbackParameters) } + listeners.flushEvents() + updateAvailableCommandsAndNotifyIfChanged() + } + + private fun updateTimelineAndNotifyIfChanged(): Boolean { + val oldTimeline: Timeline = this.myCurrentTimeline + val oldWindowIndex = currentMediaItemIndex + var playingPeriodChanged = false + if (updateTimeline()) { + val timeline: Timeline = this.myCurrentTimeline + // Call onTimelineChanged. + listeners.queueEvent( + EVENT_TIMELINE_CHANGED + ) { listener: Player.Listener -> listener.onTimelineChanged(timeline, TIMELINE_CHANGE_REASON_SOURCE_UPDATE) } + + // Call onPositionDiscontinuity if required. + val currentTimeline = currentTimeline + var playingPeriodRemoved = false + if (!oldTimeline.isEmpty) { + val oldPeriodUid = castNonNull(oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid) + playingPeriodRemoved = currentTimeline.getIndexOfPeriod(oldPeriodUid) == C.INDEX_UNSET + } + if (playingPeriodRemoved) { + var oldPosition: PositionInfo = getCurrentPositionInfo() + if (pendingMediaItemRemovalPosition != null) { + pendingMediaItemRemovalPosition?.let { + oldPosition = it + } + pendingMediaItemRemovalPosition = null + } else { + // If the media item has been removed by another client, we don't know the removal + // position. We use the current position as a fallback. + oldTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true) + oldTimeline.getWindow(period.windowIndex, window) + oldPosition = PositionInfo( + window.uid, + period.windowIndex, + window.mediaItem, + period.uid, + period.windowIndex, + currentPosition, + contentPosition, + C.INDEX_UNSET,/* adGroupIndex= */ + C.INDEX_UNSET) /* adIndexInAdGroup= */ + } + + val newPosition: PositionInfo = getCurrentPositionInfo() + listeners.queueEvent( + EVENT_POSITION_DISCONTINUITY + ) { listener: Player.Listener -> + listener.onPositionDiscontinuity( + oldPosition, newPosition, DISCONTINUITY_REASON_REMOVE) + } + } + + // Call onMediaItemTransition if required. + playingPeriodChanged = currentTimeline.isEmpty != oldTimeline.isEmpty || playingPeriodRemoved + if (playingPeriodChanged) { + listeners.queueEvent( + EVENT_MEDIA_ITEM_TRANSITION + ) { listener: Player.Listener -> + listener.onMediaItemTransition( + currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED) + } + } + updateAvailableCommandsAndNotifyIfChanged() + } + return playingPeriodChanged + } + + private fun updateInternalStateAndNotifyIfChanged() { + if (remoteMediaClient == null) { + // There is no session. We leave the state of the player as it is now. + return + } + + val oldWindowIndex = this.currentMediaItemIndex + val oldPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true).uid else null + updatePlayerStateAndNotifyIfChanged() +// updateRepeatModeAndNotifyIfChanged( /* resultCallback= */null) + updatePlaybackRateAndNotifyIfChanged() + val playingPeriodChangedByTimelineChange = updateTimelineAndNotifyIfChanged() + val currentTimeline = currentTimeline + currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, currentTimeline) + val currentPeriodUid = if (!currentTimeline.isEmpty) currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true).uid else null + if (!playingPeriodChangedByTimelineChange + && !Util.areEqual(oldPeriodUid, currentPeriodUid) + && pendingSeekCount == 0) { + // Report discontinuity and media item auto transition. + currentTimeline.getPeriod(oldWindowIndex, period, /* setIds= */true) + currentTimeline.getWindow(oldWindowIndex, window) + val windowDurationMs = window.durationMs + val oldPosition = PositionInfo( + window.uid, + period.windowIndex, + window.mediaItem, + period.uid, + period.windowIndex, /* positionMs= */ + windowDurationMs, /* contentPositionMs= */ + windowDurationMs, /* adGroupIndex= */ + C.INDEX_UNSET, /* adIndexInAdGroup= */ + C.INDEX_UNSET) + currentTimeline.getPeriod(currentMediaItemIndex, period, /* setIds= */true) + currentTimeline.getWindow(currentMediaItemIndex, window) + val newPosition = PositionInfo( + window.uid, + period.windowIndex, + window.mediaItem, + period.uid, + period.windowIndex, /* positionMs= */ + window.defaultPositionMs, /* contentPositionMs= */ + window.defaultPositionMs, /* adGroupIndex= */ + C.INDEX_UNSET, /* adIndexInAdGroup= */ + C.INDEX_UNSET) + listeners.queueEvent( + EVENT_POSITION_DISCONTINUITY + ) { listener: Listener -> + listener.onPositionDiscontinuity( + oldPosition, newPosition, DISCONTINUITY_REASON_AUTO_TRANSITION) + } + listeners.queueEvent( + EVENT_MEDIA_ITEM_TRANSITION + ) { listener: Listener -> + listener.onMediaItemTransition( + currentMediaItem, MEDIA_ITEM_TRANSITION_REASON_AUTO) + } + } + if (updateTracksAndSelectionsAndNotifyIfChanged()) { + listeners.queueEvent( + EVENT_TRACKS_CHANGED) { listener: Listener -> listener.onTracksChanged(currentTracks) } + } + updateAvailableCommandsAndNotifyIfChanged() + listeners.flushEvents() + } + + /** Updates the internal tracks and selection and returns whether they have changed. */ + @SuppressLint("WrongConstant") + private fun updateTracksAndSelectionsAndNotifyIfChanged(): Boolean { + if (remoteMediaClient == null) { + // There is no session. We leave the state of the player as it is now. + return false + } + val mediaStatus = getMediaStatus() + val mediaInfo = mediaStatus?.mediaInfo + val castMediaTracks = mediaInfo?.mediaTracks + if (castMediaTracks == null || castMediaTracks.isEmpty()) { + val hasChanged = !myCurrentTrackGroups.isEmpty + myCurrentTrackGroups = TrackGroupArray.EMPTY + myCurrentTrackSelections = EMPTY_TRACK_SELECTION_ARRAY + myCurrentTracksInfo = Tracks.EMPTY + return hasChanged + } + var activeTrackIds = mediaStatus.activeTrackIds + if (activeTrackIds == null) { + activeTrackIds = EMPTY_TRACK_ID_ARRAY + } + val trackGroups = arrayOfNulls(castMediaTracks.size) + val trackSelections = arrayOfNulls(RENDERER_COUNT) + val trackGroupInfos = arrayOfNulls(castMediaTracks.size) + for (i in castMediaTracks.indices) { + val mediaTrack = castMediaTracks[i] + trackGroups[i] = TrackGroup( /* id= */i.toString(), mediaTrackToFormat(mediaTrack)) + val id = mediaTrack.id + val trackType = MimeTypes.getTrackType(mediaTrack.contentType) + val rendererIndex: Int = getRendererIndexForTrackType(trackType) + val supported = rendererIndex != C.INDEX_UNSET + val selected = isTrackActive(id, activeTrackIds) && supported && trackSelections[rendererIndex] == null + if (selected) { + trackSelections[rendererIndex] = trackGroups[i]?.let { CastTrackSelection(it) } + } + val trackSupport = intArrayOf(if (supported) C.FORMAT_HANDLED else C.FORMAT_UNSUPPORTED_TYPE) + val trackSelected = booleanArrayOf(selected) + trackGroupInfos[i] = Group(trackGroups[i]!!, false, trackSupport, trackSelected) + } + + val tg = trackGroups.filterNotNull().toTypedArray() + val ts = trackSelections.filterNotNull().toTypedArray() + val tgi = trackGroupInfos.filterNotNull().toMutableList() + val newTrackGroups = TrackGroupArray(*tg) + val newTrackSelections = TrackSelectionArray(*ts) + val newTracksInfo = Tracks(tgi) + if (newTracksInfo != currentTracks) { + myCurrentTrackSelections = newTrackSelections + myCurrentTrackGroups = newTrackGroups + myCurrentTracksInfo = newTracksInfo + return true + } + return false + } + + /** + * Creates a [Format] instance containing all information contained in the given [ ] object. + * + * @param mediaTrack The [MediaTrack]. + * @return The equivalent [Format]. + */ + fun mediaTrackToFormat(mediaTrack: MediaTrack): Format { + return Format.Builder() + .setId(mediaTrack.contentId) + .setContainerMimeType(mediaTrack.contentType) + .setLanguage(mediaTrack.language) + .build() + } + + private fun getRendererIndexForTrackType(trackType: @TrackType Int): Int { + return if (trackType == C.TRACK_TYPE_VIDEO) RENDERER_INDEX_VIDEO else if (trackType == C.TRACK_TYPE_AUDIO) RENDERER_INDEX_AUDIO else if (trackType == C.TRACK_TYPE_TEXT) RENDERER_INDEX_TEXT else C.INDEX_UNSET + } + + private fun isTrackActive(id: Long, activeTrackIds: LongArray): Boolean { + for (activeTrackId in activeTrackIds) { + if (activeTrackId == id) { + return true + } + } + return false + } + + private fun getCurrentPositionInfo(): PositionInfo { + val currentTimeline = currentTimeline + var newPeriodUid: Any? = null + var newWindowUid: Any? = null + var newMediaItem: MediaItem? = null + if (!currentTimeline.isEmpty) { + newPeriodUid = currentTimeline.getPeriod(currentPeriodIndex, period, /* setIds= */true).uid + newWindowUid = currentTimeline.getWindow(period.windowIndex, window).uid + newMediaItem = window.mediaItem + } + return PositionInfo( + newWindowUid, + currentMediaItemIndex, + newMediaItem, + newPeriodUid, + currentPeriodIndex, + currentPosition, + contentPosition, + C.INDEX_UNSET,/* adGroupIndex= */ + C.INDEX_UNSET) /* adIndexInAdGroup= */ + } + + private fun updateTimeline(): Boolean { + val oldTimeline = this.myCurrentTimeline + val status: MediaStatus? = getMediaStatus() + + remoteMediaClient?.let { + this.myCurrentTimeline = if (status != null) timelineTracker.getCastTimeline(it) else CastTimeline.EMPTY_CAST_TIMELINE + } + + val timelineChanged = oldTimeline != this.myCurrentTimeline + if (timelineChanged) { + currentMediaItemIndex = fetchCurrentWindowIndex(remoteMediaClient, this.myCurrentTimeline) + Log.d(tag, "timelineChanged $currentMediaItemIndex") + } + return timelineChanged + } + + private fun fetchCurrentWindowIndex(remoteMediaClient: RemoteMediaClient?, timeline: Timeline): Int { + if (remoteMediaClient == null) { + return 0 + } + var currentWindowIndex = C.INDEX_UNSET + val currentItem = remoteMediaClient.currentItem + if (currentItem != null) { + currentWindowIndex = timeline.getIndexOfPeriod(currentItem.itemId) + } + if (currentWindowIndex == C.INDEX_UNSET) { + // The timeline is empty. Fall back to index 0. + currentWindowIndex = 0 + } + return currentWindowIndex + } + + private fun getMediaStatus():MediaStatus? { + return remoteMediaClient?.mediaStatus + } + + @JvmName("setSessionAvailabilityListener1") + fun setSessionAvailabilityListener(listener: SessionAvailabilityListener) { + sessionAvailabilityListener = listener + } + + override fun getApplicationLooper(): Looper { + return Looper.getMainLooper() + } + + override fun addListener(listener: Player.Listener) { + listeners.add(listener) + Log.d(tag, "addListener player listener $listener") + } + + override fun removeListener(listener: Player.Listener) { + listeners.remove(listener) + } + + override fun setMediaItems(mediaItems: MutableList, resetPosition: Boolean) { + } + + override fun setMediaItems(mediaItems: MutableList, startWindowIndex: Int, startPositionMs: Long) { + } + + override fun addMediaItems(index: Int, mediaItems: MutableList) { + } + + override fun moveMediaItems(fromIndex: Int, toIndex: Int, newIndex: Int) { + } + + override fun removeMediaItems(fromIndex: Int, toIndex: Int) { + Log.d(tag, "removeMediaItems called not configured yet $fromIndex to $toIndex") + } + + override fun getAvailableCommands(): Player.Commands { + return myAvailableCommands + } + + override fun prepare() { + } + + override fun getPlaybackState(): Int { + return currentPlaybackState + } + + override fun getPlaybackSuppressionReason(): Int { + return Player.PLAYBACK_SUPPRESSION_REASON_NONE + } + + override fun getPlayerError(): PlaybackException? { + return null + } + + override fun setPlayWhenReady(playWhenReady: Boolean) { + setPlayerStateAndNotifyIfChanged( + playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST, playbackState) + + listeners.flushEvents() + val pendingResult: PendingResult = if (playWhenReady) remoteMediaClient!!.play() else remoteMediaClient!!.pause() + + val resultCb = ResultCallback { + updatePlayerStateAndNotifyIfChanged() + } + + pendingResult.setResultCallback(resultCb) + } + + override fun getPlayWhenReady(): Boolean { + return myPlayWhenReady + } + + override fun setRepeatMode(repeatMode: Int) { + } + + override fun getRepeatMode(): Int { + return Player.REPEAT_MODE_OFF + } + + override fun setShuffleModeEnabled(shuffleModeEnabled: Boolean) { + } + + override fun getShuffleModeEnabled(): Boolean { + return false + } + + override fun isLoading(): Boolean { + return false + } + + override fun seekTo(mediaItemIndex: Int, positionMs: Long, seekCommand: Int, isRepeatingCurrentItem: Boolean) { + Log.d(tag, "seekTo $mediaItemIndex position $positionMs") + + val resultCb = ResultCallback { + val statusCode: Int = it.status.statusCode + if (statusCode != CastStatusCodes.SUCCESS && statusCode != CastStatusCodes.REPLACED) { + Log.e(tag, "Seek failed. Error code $statusCode") + } + if (--pendingSeekCount == 0) { + currentMediaItemIndex = pendingSeekWindowIndex + Log.d(tag, "seekTo CB $currentMediaItemIndex") + pendingSeekWindowIndex = C.INDEX_UNSET + pendingSeekPositionMs = C.TIME_UNSET + + // Playback state change will send metadata to client and stop seek loading + listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) } + } + } + + val mediaStatus = getMediaStatus() + + // We assume the default position is 0. There is no support for seeking to the default position + // in RemoteMediaClient. + val positionMsFinal = if (positionMs != C.TIME_UNSET) positionMs else 0 + if (mediaStatus != null) { + if (currentMediaItemIndex != mediaItemIndex) { + Log.d(tag, "seekTo: Changing media item index from $currentMediaItemIndex to $mediaItemIndex") + remoteMediaClient?.queueJumpToItem(myCurrentTimeline.getPeriod(mediaItemIndex, period).uid as Int, positionMsFinal, JSONObject())?.setResultCallback(resultCb) + } else { + Log.d(tag, "seekTo: Same media index seek to position $positionMsFinal") + val mediaSeekOptions = MediaSeekOptions.Builder().setPosition(positionMsFinal).build() + remoteMediaClient?.seek(mediaSeekOptions)?.setResultCallback(resultCb) + } + val oldPosition = getCurrentPositionInfo() + pendingSeekCount++ + pendingSeekWindowIndex = mediaItemIndex + pendingSeekPositionMs = positionMsFinal + val newPosition = getCurrentPositionInfo() + listeners.queueEvent( + EVENT_POSITION_DISCONTINUITY + ) { listener: Player.Listener -> + listener.onPositionDiscontinuity(oldPosition, newPosition, DISCONTINUITY_REASON_SEEK) + } + if (oldPosition.mediaItemIndex != newPosition.mediaItemIndex) { + val mediaItem = currentTimeline.getWindow(mediaItemIndex, window).mediaItem + listeners.queueEvent( + EVENT_MEDIA_ITEM_TRANSITION + ) { listener: Player.Listener -> listener.onMediaItemTransition(mediaItem, MEDIA_ITEM_TRANSITION_REASON_SEEK) } + } + updateAvailableCommandsAndNotifyIfChanged() + } else if (pendingSeekCount == 0) { + Log.w(tag, "seekTo Media Status is null") + // Playback state change will send metadata to client and stop seek loading + listeners.sendEvent(EVENT_PLAYBACK_STATE_CHANGED) { obj: Player.Listener -> obj.onPlaybackStateChanged(currentPlaybackState) } + } + listeners.flushEvents() + } + + private fun updateAvailableCommandsAndNotifyIfChanged() { + val previousAvailableCommands = availableCommands + myAvailableCommands = Util.getAvailableCommands(this,PERMANENT_AVAILABLE_COMMANDS) + if (availableCommands != previousAvailableCommands) { + listeners.queueEvent( + EVENT_AVAILABLE_COMMANDS_CHANGED + ) { listener: Listener -> listener.onAvailableCommandsChanged(availableCommands) } + } + } + + override fun getSeekBackIncrement(): Long { + return seekBackIncrementMs + } + + override fun getSeekForwardIncrement(): Long { + return seekForwardIncrementMs + } + + override fun getMaxSeekToPreviousPosition(): Long { + return C.DEFAULT_MAX_SEEK_TO_PREVIOUS_POSITION_MS + } + + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) { + val actualPlaybackParameters = PlaybackParameters(playbackParameters.speed) + setPlaybackParametersAndNotifyIfChanged(actualPlaybackParameters) + listeners.flushEvents() + + val pendingResult: PendingResult? = remoteMediaClient?.setPlaybackRate(actualPlaybackParameters.speed.toDouble(), /* customData= */null) + + val resultCb = ResultCallback { + updatePlaybackRateAndNotifyIfChanged() + listeners.flushEvents() + } + pendingResult?.setResultCallback(resultCb) + } + + override fun getPlaybackParameters(): PlaybackParameters { + return playbackParameters + } + + override fun stop() { + Log.d(tag, "stop CastPlayer") + currentPlaybackState = Player.STATE_IDLE + remoteMediaClient?.stop() + } + + override fun stop(reset: Boolean) { + stop() + } + + override fun release() { + val sessionManager = castContext.sessionManager + sessionManager.removeSessionManagerListener(statusListener, CastSession::class.java) + sessionManager.endCurrentSession(false) + } + + override fun getCurrentTracks(): Tracks { + return this.myCurrentTracksInfo + } + + override fun getTrackSelectionParameters(): TrackSelectionParameters { + return TrackSelectionParameters.DEFAULT_WITHOUT_CONTEXT + } + + override fun setTrackSelectionParameters(parameters: TrackSelectionParameters) {} + + override fun getMediaMetadata(): MediaMetadata { + return MediaMetadata.EMPTY + } + + override fun getPlaylistMetadata(): MediaMetadata { + return MediaMetadata.EMPTY + } + + override fun setPlaylistMetadata(mediaMetadata: MediaMetadata) { + } + + override fun getCurrentTimeline(): Timeline { + return this.myCurrentTimeline + } + + override fun getCurrentPeriodIndex(): Int { + return getCurrentMediaItemIndex() + } + + override fun getCurrentMediaItemIndex(): Int { + return if (pendingSeekWindowIndex != C.INDEX_UNSET) pendingSeekWindowIndex else currentMediaItemIndex + } + + override fun getDuration(): Long { + return contentDuration + } + + override fun getCurrentPosition(): Long { + return remoteMediaClient?.approximateStreamPosition ?: 0L + } + + override fun getBufferedPosition(): Long { + return currentPosition + } + + override fun getTotalBufferedDuration(): Long { + return 0L + } + + override fun isPlayingAd(): Boolean { + return false + } + + override fun getCurrentAdGroupIndex(): Int { + return 0 + } + + override fun getCurrentAdIndexInAdGroup(): Int { + return 0 + } + + override fun getContentPosition(): Long { + return currentPosition + } + + override fun getContentBufferedPosition(): Long { + return currentPosition + } + + override fun getAudioAttributes(): AudioAttributes { + return AudioAttributes.DEFAULT + } + + /** + * The volume of the media stream, which is what the sleep timer fades - not the volume of the + * receiver. The device volume stays where the user put it, and on a cast group the balance + * between its members is left alone, because the receiver applies this to the group's output. + */ + override fun setVolume(audioVolume: Float) { + try { + remoteMediaClient?.setStreamVolume(audioVolume.toDouble().coerceIn(0.0, 1.0)) + } catch (e: Exception) { + Log.e(tag, "Failed to set the cast stream volume", e) + } + } + + override fun getVolume(): Float { + return remoteMediaClient?.mediaStatus?.streamVolume?.toFloat() ?: 1F + } + + override fun clearVideoSurface() { + } + + override fun clearVideoSurface(surface: Surface?) { + } + + override fun setVideoSurface(surface: Surface?) { + } + + override fun setVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + } + + override fun clearVideoSurfaceHolder(surfaceHolder: SurfaceHolder?) { + } + + override fun setVideoSurfaceView(surfaceView: SurfaceView?) { + } + + override fun clearVideoSurfaceView(surfaceView: SurfaceView?) { + } + + override fun setVideoTextureView(textureView: TextureView?) { + + } + + override fun clearVideoTextureView(textureView: TextureView?) { + + } + + override fun getVideoSize(): VideoSize { + return VideoSize.UNKNOWN + } + + override fun getSurfaceSize(): Size { + return Size.UNKNOWN + } + + override fun getCurrentCues(): CueGroup { + return CueGroup.EMPTY_TIME_ZERO + } + + override fun getDeviceInfo(): DeviceInfo { + return DeviceInfo.UNKNOWN + } + + override fun getDeviceVolume(): Int { + // Short-lived cache without even-value quantization + val now = System.currentTimeMillis() + if (pendingVolume != null && (now - pendingVolumeSetAt) < 100) { + return pendingVolume!! + } + + val session = castContext.sessionManager.currentCastSession + val vol = if (session != null && !session.isMute) { + (session.volume * 100).roundToInt().coerceIn(0, 100) + } else { + 0 + } + + if (pendingVolume != null && vol == pendingVolume) { + pendingVolume = null + } + + return vol + } + + override fun isDeviceMuted(): Boolean { + val session = castContext.sessionManager.currentCastSession + return session?.isMute ?: false + } + + override fun setDeviceVolume(volume: Int) { + val session = castContext.sessionManager.currentCastSession + try { + val clamped = volume.coerceIn(0, 100) + val target = (clamped / 100.0).let { kotlin.math.round(it * 100.0) / 100.0 } + + // Cache for 100ms to return in getDeviceVolume during async period + pendingVolume = clamped + pendingVolumeSetAt = System.currentTimeMillis() + + session?.setVolume(target) + } catch (e: Exception) { + Log.e(tag, "Failed to set cast device volume", e) + } + } + + override fun increaseDeviceVolume() { + val current = getDeviceVolume() + val next = current + 1 + setDeviceVolume(next.coerceAtMost(100)) + } + + override fun decreaseDeviceVolume() { + val current = getDeviceVolume() + val next = current - 1 + setDeviceVolume(next.coerceAtLeast(0)) + } + + override fun setDeviceMuted(muted: Boolean) { + val session = castContext.sessionManager.currentCastSession + try { + session?.isMute = muted + } catch (e: Exception) { + Log.e(tag, "Failed to set cast device mute state", e) + } + } + + inner class StatusListener() : RemoteMediaClient.Callback(), SessionManagerListener, RemoteMediaClient.ProgressListener { + val TAG = "StatusListener" + + // RemoteMediaClient.ProgressListener implementation. + override fun onProgressUpdated(progressMs: Long, unusedDurationMs: Long) { + lastReportedPositionMs = progressMs + } + + // RemoteMediaClient.Callback implementation. + override fun onStatusUpdated() { + Log.d(TAG, "StatusListener status queue items " + remoteMediaClient?.mediaStatus?.queueItemCount) + if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_IDLE) { + Log.d(TAG, "StatusListener CastPlayer STATE_IDLE") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_BUFFERING) { + Log.d(TAG, "StatusListener CastPlayer BUFFERING") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_LOADING) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_LOADING") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PAUSED) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PAUSED") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_PLAYING) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_PLAYING") + } else if (remoteMediaClient?.playerState == MediaStatus.PLAYER_STATE_UNKNOWN) { + Log.d(TAG, "StatusListener CastPlayer PLAYER_STATE_UNKNOWN") + } + + updateInternalStateAndNotifyIfChanged() + } + + override fun onMetadataUpdated() {} + override fun onQueueStatusUpdated() { + Log.d(TAG, "onQueueStatusUpdated") + updateTimelineAndNotifyIfChanged() + listeners.flushEvents() + } + + override fun onPreloadStatusUpdated() {} + override fun onSendingRemoteMediaRequest() {} + override fun onAdBreakStatusUpdated() {} + + // SessionManagerListener implementation. + override fun onSessionStarted(castSession: CastSession, s: String) { + Log.d(TAG, "StatusListener onSessionStarted") + setRemoteMediaClient(castSession.remoteMediaClient) + } + + override fun onSessionResumed(castSession: CastSession, b: Boolean) { + setRemoteMediaClient(castSession.remoteMediaClient) + } + + override fun onSessionEnded(castSession: CastSession, i: Int) { + Log.d(TAG, "StatusListener onSessionEnded") + setRemoteMediaClient(null) + } + + override fun onSessionSuspended(castSession: CastSession, i: Int) { + setRemoteMediaClient(null) + } + + override fun onSessionResumeFailed(castSession: CastSession, statusCode: Int) { + Log.e( + TAG, + "Session resume failed. Error code " + + statusCode) + } + + override fun onSessionStarting(castSession: CastSession) { + Log.d(TAG, "StatusListener onSessionStarting") + // Do nothing. + } + + override fun onSessionStartFailed(castSession: CastSession, statusCode: Int) { + Log.e( + TAG, + ("Session start failed. Error code " + + statusCode)) + } + + override fun onSessionEnding(castSession: CastSession) { + // Do nothing. + } + + override fun onSessionResuming(castSession: CastSession, s: String) { + // Do nothing. + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt new file mode 100644 index 0000000..a241547 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimeline.kt @@ -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) : 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 + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt new file mode 100644 index 0000000..56d986e --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastTimelineTracker.kt @@ -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 + + /** + * 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( /* 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() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt new file mode 100644 index 0000000..33f4d84 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CastTrackSelection.kt @@ -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 + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt b/android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt new file mode 100644 index 0000000..5acd574 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/CoverImageLoader.kt @@ -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() + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt new file mode 100644 index 0000000..c87e912 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionCallback.kt @@ -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() + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt new file mode 100644 index 0000000..bc59d04 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/MediaSessionPlaybackPreparer.kt @@ -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") + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt new file mode 100644 index 0000000..35d787a --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerConstants.kt @@ -0,0 +1,21 @@ +package com.audiobookshelf.app.player + +const val CUSTOM_ACTION_JUMP_FORWARD = "com.audiobookshelf.customAction.jump_forward"; +const val CUSTOM_ACTION_JUMP_BACKWARD = "com.audiobookshelf.customAction.jump_backward"; +const val CUSTOM_ACTION_SKIP_FORWARD = "com.audiobookshelf.customAction.skip_forward"; +const val CUSTOM_ACTION_SKIP_BACKWARD = "com.audiobookshelf.customAction.skip_backward"; +const val CUSTOM_ACTION_CHANGE_SPEED = "com.audiobookshelf.customAction.change_speed"; + +const val PLAYMETHOD_DIRECTPLAY = 0 +const val PLAYMETHOD_DIRECTSTREAM = 1 +const val PLAYMETHOD_TRANSCODE = 2 +const val PLAYMETHOD_LOCAL = 3 + +// +// MEDIA BROWSER STUFF (ANDROID AUTO) +// +const val ANDROID_AUTO_PKG_NAME = "com.google.android.projection.gearhead" +const val ANDROID_AUTO_SIMULATOR_PKG_NAME = "com.google.android.autosimulator" +const val ANDROID_WEARABLE_PKG_NAME = "com.google.android.wearable.app" +const val ANDROID_GSEARCH_PKG_NAME = "com.google.android.googlequicksearchbox" +const val ANDROID_AUTOMOTIVE_PKG_NAME = "com.google.android.carassistant" diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt new file mode 100644 index 0000000..8d28b63 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerListener.kt @@ -0,0 +1,174 @@ +package com.audiobookshelf.app.player + +import android.util.Log +import com.audiobookshelf.app.data.PlaybackSession +import com.audiobookshelf.app.data.PlayerState +import com.audiobookshelf.app.device.DeviceManager +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.Player + +//const val PAUSE_LEN_BEFORE_RECHECK = 30000 // 30 seconds + +class PlayerListener(var playerNotificationService:PlayerNotificationService) : Player.Listener { + var tag = "PlayerListener" + + companion object { + var lastPauseTime: Long = 0 //ms + var lazyIsPlaying: Boolean = false + } + + override fun onPlayerError(error: PlaybackException) { + val errorMessage = error.message ?: "Unknown Error" + Log.e(tag, "onPlayerError $errorMessage") + playerNotificationService.handlePlayerPlaybackError(errorMessage) // If was direct playing session, fallback to transcode + } + + override fun onPositionDiscontinuity( + oldPosition: Player.PositionInfo, + newPosition: Player.PositionInfo, + reason: Int + ) { + if (reason == Player.DISCONTINUITY_REASON_SEEK) { + // If playing set seeking flag + Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying} reason=SEEK") + playerNotificationService.mediaProgressSyncer.seek() + lastPauseTime = 0 // When seeking while paused reset the auto-rewind + } else { + Log.d(tag, "onPositionDiscontinuity: oldPosition=${oldPosition.positionMs}/${oldPosition.mediaItemIndex}, newPosition=${newPosition.positionMs}/${newPosition.mediaItemIndex}, isPlaying=${playerNotificationService.currentPlayer.isPlaying}, reason=$reason") + } + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + Log.d(tag, "onIsPlayingChanged to $isPlaying | ${playerNotificationService.getMediaPlayer()} | playbackState=${playerNotificationService.currentPlayer.playbackState}") + + val player = playerNotificationService.currentPlayer + + // Goal of these 2 if statements and the lazyIsPlaying is to ignore this event when it is triggered by a seek + // When a seek occurs the player is paused and buffering, then plays again right afterwards. + if (!isPlaying && player.playbackState == Player.STATE_BUFFERING) { + Log.d(tag, "onIsPlayingChanged: Pause event when buffering is ignored") + return + } + if (lazyIsPlaying == isPlaying) { + Log.d(tag, "onIsPlayingChanged: Lazy is playing $lazyIsPlaying is already set to this so ignoring") + return + } + + lazyIsPlaying = isPlaying + + // Update widget + DeviceManager.widgetUpdater?.onPlayerChanged(playerNotificationService) + + if (isPlaying) { + Log.d(tag, "SeekBackTime: Player is playing") + if (lastPauseTime > 0 && DeviceManager.deviceData.deviceSettings?.disableAutoRewind != true) { + Log.d(tag, "SeekBackTime: playing started now set seek back time $lastPauseTime") + var seekBackTime = calcPauseSeekBackTime() + if (seekBackTime > 0) { + // Current chapter is used so that seek back does not go back to the previous chapter + val currentChapter = playerNotificationService.getCurrentBookChapter() + val minSeekBackTime = currentChapter?.startMs ?: 0 + + val currentTime = playerNotificationService.getCurrentTime() + val newTime = currentTime - seekBackTime + if (newTime < minSeekBackTime) { + seekBackTime = currentTime - minSeekBackTime + } + Log.d(tag, "SeekBackTime $seekBackTime") + } + + // TODO: this needs to be reworked so that the audio doesn't start playing before it checks for updated progress + // Check if playback session still exists or sync media progress if updated +// val pauseLength: Long = System.currentTimeMillis() - lastPauseTime +// if (pauseLength > PAUSE_LEN_BEFORE_RECHECK) { +// val shouldCarryOn = playerNotificationService.checkCurrentSessionProgress(seekBackTime) +// if (!shouldCarryOn) return +// } + + if (seekBackTime > 0L) { + playerNotificationService.seekBackward(seekBackTime) + } + } + } else { + Log.d(tag, "SeekBackTime: Player not playing set last pause time | playbackState=${player.playbackState}") + lastPauseTime = System.currentTimeMillis() + } + + // Start/stop progress sync interval + if (isPlaying) { + val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession + playbackSession?.let { + // Handles auto-starting sleep timer and resetting sleep timer + playerNotificationService.sleepTimerManager.handleMediaPlayEvent(it.id) + + player.volume = 1F // Volume on sleep timer might have decreased this + + playerNotificationService.mediaProgressSyncer.play(it) + } + } else { + playerNotificationService.mediaProgressSyncer.pause { + Log.d(tag, "Media Progress Syncer paused and synced") + } + } + + playerNotificationService.clientEventEmitter?.onPlayingUpdate(isPlaying) + } + + override fun onEvents(player: Player, events: Player.Events) { + Log.d(tag, "onEvents ${playerNotificationService.getMediaPlayer()} | ${events.size()}") + + if (events.contains(Player.EVENT_POSITION_DISCONTINUITY)) { + Log.d(tag, "EVENT_POSITION_DISCONTINUITY") + } + + if (events.contains(Player.EVENT_IS_LOADING_CHANGED)) { + Log.d(tag, "EVENT_IS_LOADING_CHANGED : " + playerNotificationService.currentPlayer.isLoading) + } + + if (events.contains(Player.EVENT_PLAYBACK_STATE_CHANGED)) { + Log.d(tag, "EVENT_PLAYBACK_STATE_CHANGED MediaPlayer = ${playerNotificationService.getMediaPlayer()}") + + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_READY) { + Log.d(tag, "STATE_READY : " + playerNotificationService.currentPlayer.duration) + + if (lastPauseTime == 0L) { + lastPauseTime = -1 + } + playerNotificationService.sendClientMetadata(PlayerState.READY) + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_BUFFERING) { + Log.d(tag, "STATE_BUFFERING : " + playerNotificationService.currentPlayer.currentPosition) + playerNotificationService.sendClientMetadata(PlayerState.BUFFERING) + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_ENDED) { + Log.d(tag, "STATE_ENDED") + playerNotificationService.sendClientMetadata(PlayerState.ENDED) + + playerNotificationService.handlePlaybackEnded() + } + if (playerNotificationService.currentPlayer.playbackState == Player.STATE_IDLE) { + Log.d(tag, "STATE_IDLE") + playerNotificationService.sendClientMetadata(PlayerState.IDLE) + } + } + + if (events.contains(Player.EVENT_MEDIA_METADATA_CHANGED)) { + Log.d(tag, "EVENT_MEDIA_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}") + } + if (events.contains(Player.EVENT_PLAYLIST_METADATA_CHANGED)) { + Log.d(tag, "EVENT_PLAYLIST_METADATA_CHANGED ${playerNotificationService.getMediaPlayer()}") + } + } + + private fun calcPauseSeekBackTime() : Long { + if (lastPauseTime <= 0) return 0 + val time: Long = System.currentTimeMillis() - lastPauseTime + val seekback: Long + if (time < 10000) seekback = 0 // 10s or less = no seekback + else if (time < 60000) seekback = 3000 // 10s to 1m = jump back 3s + else if (time < 300000) seekback = 10000 // 1m to 5m = jump back 10s + else if (time < 1800000) seekback = 20000 // 5m to 30m = jump back 20s + else seekback = 29500 // 30m and up = jump back 30s + return seekback + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt new file mode 100644 index 0000000..6ac0721 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationListener.kt @@ -0,0 +1,56 @@ +package com.audiobookshelf.app.player + +import android.app.Notification +import android.content.pm.ServiceInfo +import android.os.Build +import android.util.Log +import com.google.android.exoplayer2.ui.PlayerNotificationManager + +class PlayerNotificationListener(var playerNotificationService:PlayerNotificationService) : PlayerNotificationManager.NotificationListener { + var tag = "PlayerNotificationListener" + + companion object { + var isForegroundService = false + } + + override fun onNotificationPosted( + notificationId: Int, + notification: Notification, + onGoing: Boolean) { + + if (onGoing && !isForegroundService) { + // Start foreground service + Log.d(tag, "Notification Posted $notificationId - Start Foreground | $notification") + PlayerNotificationService.isClosed = false + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + playerNotificationService.startForeground(notificationId, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) + } else { + playerNotificationService.startForeground(notificationId, notification) + } + isForegroundService = true + } else { + Log.d(tag, "Notification posted $notificationId, not starting foreground - onGoing=$onGoing | isForegroundService=$isForegroundService") + } + } + + override fun onNotificationCancelled( + notificationId: Int, + dismissedByUser: Boolean + ) { + if (dismissedByUser) { + Log.d(tag, "onNotificationCancelled dismissed by user") + playerNotificationService.stopSelf() + } else { + Log.d(tag, "onNotificationCancelled not dismissed by user") + + if (PlayerNotificationService.isSwitchingPlayer) { + // When switching from cast player to exo player and vice versa the notification is cancelled and posted again + // so we don't want to cancel the playback during this switch + Log.d(tag, "PNS is switching player") + PlayerNotificationService.isSwitchingPlayer = false + } + } + isForegroundService = false + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt new file mode 100644 index 0000000..86d4bdc --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/PlayerNotificationService.kt @@ -0,0 +1,2221 @@ +package com.audiobookshelf.app.player + +import android.annotation.SuppressLint +import android.app.* +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.ImageDecoder +import android.hardware.Sensor +import android.hardware.SensorManager +import android.net.* +import android.os.* +import android.provider.MediaStore +import android.provider.Settings +import android.support.v4.media.MediaBrowserCompat +import android.support.v4.media.MediaDescriptionCompat +import android.support.v4.media.MediaMetadataCompat +import android.support.v4.media.session.MediaControllerCompat +import android.support.v4.media.session.MediaSessionCompat +import androidx.media.VolumeProviderCompat +import android.media.AudioManager +import android.support.v4.media.session.PlaybackStateCompat +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationCompat +import androidx.core.content.ContextCompat +import androidx.media.MediaBrowserServiceCompat +import androidx.media.utils.MediaConstants +import com.audiobookshelf.app.BuildConfig +import com.audiobookshelf.app.R +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.data.DeviceInfo +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.managers.DbManager +import com.audiobookshelf.app.managers.SleepTimerManager +import com.audiobookshelf.app.media.MediaManager +import com.audiobookshelf.app.media.MediaProgressSyncer +import com.audiobookshelf.app.media.getUriToAbsIconDrawable +import com.audiobookshelf.app.media.getUriToDrawable +import com.audiobookshelf.app.plugins.AbsLogger +import com.audiobookshelf.app.server.ApiHandler +import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector +import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector.CustomActionProvider +import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator +import com.google.android.exoplayer2.extractor.DefaultExtractorsFactory +import com.google.android.exoplayer2.extractor.mp3.Mp3Extractor +import com.google.android.exoplayer2.source.MediaSource +import com.google.android.exoplayer2.source.ProgressiveMediaSource +import com.google.android.exoplayer2.source.hls.HlsMediaSource +import com.google.android.exoplayer2.ui.PlayerNotificationManager +import com.google.android.exoplayer2.upstream.* +import java.util.* +import kotlin.concurrent.schedule +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking + +const val SLEEP_TIMER_WAKE_UP_EXPIRATION = 120000L // 2m +const val PLAYER_CAST = "cast-player" +const val PLAYER_EXO = "exo-player" + +class PlayerNotificationService : MediaBrowserServiceCompat() { + + companion object { + var isStarted = false + var isClosed = false + var isUnmeteredNetwork = false + var hasNetworkConnectivity = false // Not 100% reliable has internet + var isSwitchingPlayer = false // Used when switching between cast player and exoplayer + } + + private val tag = "PlayerNotificationServ" + + interface ClientEventEmitter { + fun onPlaybackSession(playbackSession: PlaybackSession) + fun onPlaybackClosed() + fun onPlayingUpdate(isPlaying: Boolean) + fun onMetadata(metadata: PlaybackMetadata) + fun onSleepTimerEnded(currentPosition: Long) + fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer: Boolean) + fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) + fun onPlaybackFailed(errorMessage: String) + fun onMediaPlayerChanged(mediaPlayer: String) + fun onProgressSyncFailing() + fun onProgressSyncSuccess() + fun onNetworkMeteredChanged(isUnmetered: Boolean) + fun onMediaItemHistoryUpdated(mediaItemHistory: MediaItemHistory) + fun onPlaybackSpeedChanged(playbackSpeed: Float) + } + private val binder = LocalBinder() + + var clientEventEmitter: ClientEventEmitter? = null + + private lateinit var ctx: Context + private lateinit var mediaSessionConnector: MediaSessionConnector + private lateinit var playerNotificationManager: PlayerNotificationManager + lateinit var mediaSession: MediaSessionCompat + private var remoteVolumeProvider: VolumeProviderCompat? = null + private lateinit var transportControls: MediaControllerCompat.TransportControls + + lateinit var mediaManager: MediaManager + lateinit var apiHandler: ApiHandler + + lateinit var mPlayer: ExoPlayer + lateinit var currentPlayer: Player + var castPlayer: CastPlayer? = null + + lateinit var sleepTimerManager: SleepTimerManager + lateinit var mediaProgressSyncer: MediaProgressSyncer + + private var notificationId = 10 + private var channelId = "audiobookshelf_channel" + private var channelName = "Audiobookshelf Channel" + + var currentPlaybackSession: PlaybackSession? = null + private var initialPlaybackRate: Float? = null + + private val metadataScope = CoroutineScope(Dispatchers.Main + SupervisorJob()) + private var metadataArtJob: Job? = null + + private var isAndroidAuto = false + + // The following are used for the shake detection + private var isShakeSensorRegistered: Boolean = false + private var mSensorManager: SensorManager? = null + private var mAccelerometer: Sensor? = null + private var mShakeDetector: ShakeDetector? = null + private var shakeSensorUnregisterTask: TimerTask? = null + + // These are used to trigger reloading if + private var forceReloadingAndroidAuto: Boolean = false + private var firstLoadDone: Boolean = false + + fun isBrowseTreeInitialized(): Boolean { + return this::browseTree.isInitialized + } + + // Cache latest search so it wont trigger again when returning from series for example + private var cachedSearch: String = "" + private var cachedSearchResults: MutableList = mutableListOf() + + /* + Service related stuff + */ + override fun onBind(intent: Intent): IBinder? { + Log.d(tag, "onBind") + + // Android Auto Media Browser Service + if (SERVICE_INTERFACE == intent.action) { + Log.d(tag, "Is Media Browser Service") + return super.onBind(intent) + } + return binder + } + + inner class LocalBinder : Binder() { + // Return this instance of LocalService so clients can call public methods + fun getService(): PlayerNotificationService = this@PlayerNotificationService + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + isStarted = true + Log.d(tag, "onStartCommand $startId") + + return START_STICKY + } + + @Deprecated("Deprecated in Java") + override fun onStart(intent: Intent?, startId: Int) { + Log.d(tag, "onStart $startId") + } + + @RequiresApi(Build.VERSION_CODES.O) + private fun createNotificationChannel(channelId: String, channelName: String): String { + val chan = NotificationChannel(channelId, channelName, NotificationManager.IMPORTANCE_LOW) + chan.lightColor = Color.DKGRAY + chan.lockscreenVisibility = Notification.VISIBILITY_PUBLIC + val service = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + service.createNotificationChannel(chan) + return channelId + } + + // detach player + override fun onDestroy() { + try { + val connectivityManager = + getSystemService(ConnectivityManager::class.java) as ConnectivityManager + connectivityManager.unregisterNetworkCallback(networkCallback) + } catch (error: Exception) { + Log.e(tag, "Error unregistering network listening callback $error") + } + + Log.d(tag, "onDestroy") + isStarted = false + isClosed = true + DeviceManager.widgetUpdater?.onPlayerChanged(this) + + playerNotificationManager.setPlayer(null) + mPlayer.release() + castPlayer?.release() + mediaSession.release() + mediaProgressSyncer.reset() + metadataScope.cancel() + + super.onDestroy() + } + + // removing service when user swipe out our app + override fun onTaskRemoved(rootIntent: Intent?) { + super.onTaskRemoved(rootIntent) + Log.d(tag, "onTaskRemoved") + + stopSelf() + } + + override fun onCreate() { + Log.d(tag, "onCreate") + super.onCreate() + ctx = this + + // Initialize Paper + DbManager.initialize(ctx) + + // Initialize widget + DeviceManager.initializeWidgetUpdater(ctx) + + // To listen for network change from metered to unmetered + val networkRequest = + NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_METERED) + .addCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + .build() + val connectivityManager = + getSystemService(ConnectivityManager::class.java) as ConnectivityManager + connectivityManager.registerNetworkCallback(networkRequest, networkCallback) + + DbManager.initialize(ctx) + + // Initialize API + apiHandler = ApiHandler(ctx) + + // Initialize sleep timer + sleepTimerManager = SleepTimerManager(this) + + // Initialize Media Progress Syncer + mediaProgressSyncer = MediaProgressSyncer(this, apiHandler) + + // Initialize shake sensor + Log.d(tag, "onCreate Register sensor listener ${mAccelerometer?.isWakeUpSensor}") + initSensor() + + // Initialize media manager + mediaManager = MediaManager(apiHandler, ctx) + + channelId = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + createNotificationChannel(channelId, channelName) + } else "" + + val sessionActivityPendingIntent = + packageManager?.getLaunchIntentForPackage(packageName)?.let { sessionIntent -> + PendingIntent.getActivity(this, 0, sessionIntent, PendingIntent.FLAG_IMMUTABLE) + } + + mediaSession = + MediaSessionCompat(this, tag).apply { + setSessionActivity(sessionActivityPendingIntent) + isActive = true + } + + val mediaController = MediaControllerCompat(ctx, mediaSession.sessionToken) + + // This is for Media Browser + sessionToken = mediaSession.sessionToken + + val builder = PlayerNotificationManager.Builder(ctx, notificationId, channelId) + + builder.setMediaDescriptionAdapter(AbMediaDescriptionAdapter(mediaController, this)) + builder.setNotificationListener(PlayerNotificationListener(this)) + + playerNotificationManager = builder.build() + playerNotificationManager.setMediaSessionToken(mediaSession.sessionToken) + playerNotificationManager.setUsePlayPauseActions(true) + playerNotificationManager.setUseNextAction(false) + playerNotificationManager.setUsePreviousAction(false) + playerNotificationManager.setUseChronometer(false) + playerNotificationManager.setUseStopAction(false) + playerNotificationManager.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + playerNotificationManager.setPriority(NotificationCompat.PRIORITY_MAX) + playerNotificationManager.setUseFastForwardActionInCompactView(true) + playerNotificationManager.setUseRewindActionInCompactView(true) + playerNotificationManager.setSmallIcon(R.drawable.icon_monochrome) + + // Unknown action + playerNotificationManager.setBadgeIconType(NotificationCompat.BADGE_ICON_LARGE) + + transportControls = mediaController.transportControls + + mediaSessionConnector = MediaSessionConnector(mediaSession) + // Without this, the connector's default metadata provider rebuilds metadata from the + // player's own state on media item transitions/timeline changes, dropping the cover art + // bitmap that PlaybackSession.resolveCoverBitmapAsync resolves separately. + mediaSessionConnector.setMediaMetadataProvider { _ -> + currentPlaybackSession?.getMediaMetadataCompat(ctx) ?: MediaMetadataCompat.Builder().build() + } + val queueNavigator: TimelineQueueNavigator = + object : TimelineQueueNavigator(mediaSession) { + override fun getSupportedQueueNavigatorActions(player: Player): Long { + return PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE + } + + override fun getMediaDescription( + player: Player, + windowIndex: Int + ): MediaDescriptionCompat { + if (currentPlaybackSession == null) { + Log.e(tag, "Playback session is not set - returning blank MediaDescriptionCompat") + return MediaDescriptionCompat.Builder().build() + } + + val coverUri = currentPlaybackSession!!.getCoverUri(ctx) + + + var bitmap: Bitmap? = null + // Local covers get bitmap + // Note: In Android Auto for local cover images, setting the icon uri to a local path does not work (cover is blank) + // so we create and set the bitmap here instead of AbMediaDescriptionAdapter + if (currentPlaybackSession!!.localLibraryItem?.coverContentUrl != null) { + bitmap = + if (Build.VERSION.SDK_INT < 28) { + MediaStore.Images.Media.getBitmap(ctx.contentResolver, coverUri) + } else { + val source: ImageDecoder.Source = + ImageDecoder.createSource(ctx.contentResolver, coverUri) + ImageDecoder.decodeBitmap(source) + } + } + + // Fix for local images crashing on Android 11 for specific devices + // https://stackoverflow.com/questions/64186578/android-11-mediastyle-notification-crash/64232958#64232958 + try { + ctx.grantUriPermission( + "com.android.systemui", + coverUri, + Intent.FLAG_GRANT_READ_URI_PERMISSION + ) + } catch (error: Exception) { + Log.e(tag, "Grant uri permission error $error") + } + + val extra = Bundle() + extra.putString( + MediaMetadataCompat.METADATA_KEY_ARTIST, + currentPlaybackSession!!.displayAuthor + ) + + val mediaDescriptionBuilder = + MediaDescriptionCompat.Builder() + .setExtras(extra) + .setTitle(currentPlaybackSession!!.displayTitle) + + bitmap?.let { mediaDescriptionBuilder.setIconBitmap(it) } + ?: mediaDescriptionBuilder.setIconUri(coverUri) + + return mediaDescriptionBuilder.build() + } + } + + setMediaSessionConnectorPlaybackActions() + mediaSessionConnector.setQueueNavigator(queueNavigator) + mediaSessionConnector.setPlaybackPreparer(MediaSessionPlaybackPreparer(this)) + + mediaSession.setCallback(MediaSessionCallback(this)) + + initializeMPlayer() + currentPlayer = mPlayer + } + + private fun initializeMPlayer() { + val customLoadControl: LoadControl = + DefaultLoadControl.Builder() + .setBufferDurationsMs( + 1000 * 20, // 20s min buffer + 1000 * 45, // 45s max buffer + 1000 * 5, // 5s playback start + 1000 * 20 // 20s playback rebuffer + ) + .build() + + mPlayer = + ExoPlayer.Builder(this) + .setLoadControl(customLoadControl) + .setSeekBackIncrementMs(deviceSettings.jumpBackwardsTimeMs) + .setSeekForwardIncrementMs(deviceSettings.jumpForwardTimeMs) + .build() + mPlayer.setHandleAudioBecomingNoisy(true) + mPlayer.addListener(PlayerListener(this)) + val audioAttributes: AudioAttributes = + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_SPEECH) + .build() + mPlayer.setAudioAttributes(audioAttributes, true) + + // attach player to playerNotificationManager + playerNotificationManager.setPlayer(mPlayer) + + mediaSessionConnector.setPlayer(mPlayer) + } + + /* + User callable methods + */ + fun preparePlayer( + playbackSession: PlaybackSession, + playWhenReady: Boolean, + playbackRate: Float? + ) { + if (!isStarted) { + Log.i(tag, "preparePlayer: foreground service not started - Starting service --") + Intent(ctx, PlayerNotificationService::class.java).also { intent -> + ContextCompat.startForegroundService(ctx, intent) + } + } + + // TODO: When an item isFinished the currentTime should be reset to 0 + // will reset the time if currentTime is within 5s of duration (for android auto) + Log.d( + tag, + "Prepare Player Session Current Time=${playbackSession.currentTime}, Duration=${playbackSession.duration}" + ) + if (playbackSession.duration - playbackSession.currentTime < 5) { + Log.d(tag, "Prepare Player Session is finished, so restart it") + playbackSession.currentTime = 0.0 + } + + isClosed = false + + val mediaItems = playbackSession.getMediaItems(ctx) + val playbackRateToUse = playbackRate ?: initialPlaybackRate ?: 1f + initialPlaybackRate = playbackRate + + // Set actions on Android Auto like jump forward/backward + setMediaSessionConnectorCustomActions(playbackSession) + + playbackSession.mediaPlayer = getMediaPlayer() + + if (playbackSession.mediaPlayer == PLAYER_CAST && playbackSession.isLocal) { + Log.w(tag, "Cannot cast local media item - switching player") + currentPlaybackSession = null + switchToPlayer(false) + playbackSession.mediaPlayer = getMediaPlayer() + } + + if (playbackSession.mediaPlayer == PLAYER_CAST) { + // If cast-player is the first player to be used + mediaSessionConnector.setPlayer(castPlayer) + playerNotificationManager.setPlayer(castPlayer) + } + + currentPlaybackSession = playbackSession + DeviceManager.setLastPlaybackSession( + playbackSession + ) // Save playback session to use when app is closed + + metadataArtJob?.cancel() + metadataArtJob = + playbackSession.resolveCoverBitmapAsync(ctx, metadataScope) { + mediaSessionConnector.invalidateMediaSessionMetadata() + } + mediaSessionConnector.invalidateMediaSessionMetadata() + + AbsLogger.info("PlayerNotificationService", "preparePlayer: Started playback session for item ${currentPlaybackSession?.mediaItemId}. MediaPlayer ${currentPlaybackSession?.mediaPlayer}") + // Notify client + clientEventEmitter?.onPlaybackSession(playbackSession) + + // Update widget + DeviceManager.widgetUpdater?.onPlayerChanged(this) + + if (mediaItems.isEmpty()) { + Log.e(tag, "Invalid playback session no media items to play") + currentPlaybackSession = null + return + } + + if (mPlayer == currentPlayer) { + val mediaSource: MediaSource + + if (playbackSession.isLocal) { + AbsLogger.info("PlayerNotificationService", "preparePlayer: Playing local item ${currentPlaybackSession?.mediaItemId}.") + val dataSourceFactory = DefaultDataSource.Factory(ctx) + + val extractorsFactory = DefaultExtractorsFactory() + extractorsFactory.setConstantBitrateSeekingEnabled(true) + + if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) { + // @see + // https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files + extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING) + } + + mediaSource = + ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + .createMediaSource(mediaItems[0]) + } else if (!playbackSession.isHLS) { + AbsLogger.info("PlayerNotificationService", "preparePlayer: Direct playing item ${currentPlaybackSession?.mediaItemId}.") + val dataSourceFactory = DefaultHttpDataSource.Factory() + + val extractorsFactory = DefaultExtractorsFactory() + extractorsFactory.setConstantBitrateSeekingEnabled(true) + + if (DeviceManager.deviceData.deviceSettings?.enableMp3IndexSeeking == true) { + // @see + // https://exoplayer.dev/troubleshooting.html#why-is-seeking-inaccurate-in-some-mp3-files + extractorsFactory.setMp3ExtractorFlags(Mp3Extractor.FLAG_ENABLE_INDEX_SEEKING) + } + + dataSourceFactory.setUserAgent(channelId) + mediaSource = + ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory) + .createMediaSource(mediaItems[0]) + } else { + AbsLogger.info("PlayerNotificationService", "preparePlayer: Playing HLS stream of item ${currentPlaybackSession?.mediaItemId}.") + val dataSourceFactory = DefaultHttpDataSource.Factory() + dataSourceFactory.setUserAgent(channelId) + dataSourceFactory.setDefaultRequestProperties( + hashMapOf("Authorization" to "Bearer ${DeviceManager.token}") + ) + mediaSource = HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItems[0]) + } + mPlayer.setMediaSource(mediaSource) + + // Add remaining media items if multi-track + if (mediaItems.size > 1) { + currentPlayer.addMediaItems(mediaItems.subList(1, mediaItems.size)) + Log.d(tag, "currentPlayer total media items ${currentPlayer.mediaItemCount}") + + val currentTrackIndex = playbackSession.getCurrentTrackIndex() + val currentTrackTime = playbackSession.getCurrentTrackTimeMs() + Log.d( + tag, + "currentPlayer current track index $currentTrackIndex & current track time $currentTrackTime" + ) + currentPlayer.seekTo(currentTrackIndex, currentTrackTime) + } else { + currentPlayer.seekTo(playbackSession.currentTimeMs) + } + + Log.d( + tag, + "Prepare complete for session ${currentPlaybackSession?.displayTitle} | ${currentPlayer.mediaItemCount}" + ) + currentPlayer.playWhenReady = playWhenReady + currentPlayer.setPlaybackSpeed(playbackRateToUse) + + currentPlayer.prepare() + } else if (castPlayer != null) { + val currentTrackIndex = playbackSession.getCurrentTrackIndex() + val currentTrackTime = playbackSession.getCurrentTrackTimeMs() + val mediaType = playbackSession.mediaType + Log.d(tag, "Loading cast player $currentTrackIndex $currentTrackTime $mediaType") + + castPlayer?.load( + mediaItems, + currentTrackIndex, + currentTrackTime, + playWhenReady, + playbackRateToUse, + mediaType + ) + } + } + + private fun setMediaSessionConnectorCustomActions(playbackSession: PlaybackSession) { + val mediaItems = playbackSession.getMediaItems(ctx) + val customActionProviders = + mutableListOf( + JumpBackwardCustomActionProvider(), + JumpForwardCustomActionProvider(), + ChangePlaybackSpeedCustomActionProvider() // Will be pushed to far left + ) + if (playbackSession.mediaPlayer != PLAYER_CAST && mediaItems.size > 1) { + customActionProviders.addAll( + listOf( + SkipBackwardCustomActionProvider(), + SkipForwardCustomActionProvider(), + ) + ) + } + mediaSessionConnector.setCustomActionProviders(*customActionProviders.toTypedArray()) + } + + fun setMediaSessionConnectorPlaybackActions() { + var playbackActions = + PlaybackStateCompat.ACTION_PLAY_PAUSE or + PlaybackStateCompat.ACTION_PLAY or + PlaybackStateCompat.ACTION_PAUSE or + PlaybackStateCompat.ACTION_FAST_FORWARD or + PlaybackStateCompat.ACTION_REWIND or + PlaybackStateCompat.ACTION_STOP + + if (deviceSettings.allowSeekingOnMediaControls) { + playbackActions = playbackActions or PlaybackStateCompat.ACTION_SEEK_TO + } + mediaSessionConnector.setEnabledPlaybackActions(playbackActions) + } + + fun handlePlayerPlaybackError(errorMessage: String) { + // On error and was attempting to direct play - fallback to transcode + currentPlaybackSession?.let { playbackSession -> + if (playbackSession.isDirectPlay) { + val playItemRequestPayload = getPlayItemRequestPayload(true) + Log.d(tag, "Fallback to transcode $playItemRequestPayload.mediaPlayer") + + val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play + val episodeId = playbackSession.episodeId + mediaProgressSyncer.stop(false) { + apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { + if (it == null) { // Play request failed + clientEventEmitter?.onPlaybackFailed(errorMessage) + closePlayback(true) + } else { + Handler(Looper.getMainLooper()).post { preparePlayer(it, true, null) } + } + } + } + } else { + clientEventEmitter?.onPlaybackFailed(errorMessage) + closePlayback(true) + } + } + } + + fun handlePlaybackEnded() { + Log.d(tag, "handlePlaybackEnded") + if (isAndroidAuto && currentPlaybackSession?.isPodcastEpisode == true) { + Log.d(tag, "Podcast playback ended on android auto") + val libraryItem = currentPlaybackSession?.libraryItem ?: return + + // Need to sync with server to set as finished + mediaProgressSyncer.finished { + // Need to reload media progress + mediaManager.loadServerUserMediaProgress { + val podcast = libraryItem.media as Podcast + val nextEpisode = podcast.getNextUnfinishedEpisode(libraryItem.id, mediaManager) + Log.d(tag, "handlePlaybackEnded nextEpisode=$nextEpisode") + nextEpisode?.let { podcastEpisode -> + mediaManager.play(libraryItem, podcastEpisode, getPlayItemRequestPayload(false)) { + if (it == null) { + Log.e(tag, "Failed to play library item") + } else { + val playbackRate = mediaManager.getSavedPlaybackRate() + Handler(Looper.getMainLooper()).post { preparePlayer(it, true, playbackRate) } + } + } + } + } + } + } + } + + fun startNewPlaybackSession() { + currentPlaybackSession?.let { playbackSession -> + Log.i(tag, "Starting new playback session for ${playbackSession.displayTitle}") + + val forceTranscode = playbackSession.isHLS // If already HLS then force + val playItemRequestPayload = getPlayItemRequestPayload(forceTranscode) + + val libraryItemId = playbackSession.libraryItemId ?: "" // Must be true since direct play + val episodeId = playbackSession.episodeId + mediaProgressSyncer.stop(false) { + apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { + if (it == null) { + Log.e(tag, "Failed to start new playback session") + } else { + Log.d( + tag, + "New playback session response from server with session id ${it.id} for \"${it.displayTitle}\"" + ) + Handler(Looper.getMainLooper()).post { preparePlayer(it, true, null) } + } + } + } + } + } + + fun switchToPlayer(useCastPlayer: Boolean) { + val wasPlaying = currentPlayer.isPlaying + if (useCastPlayer) { + if (currentPlayer == castPlayer) { + Log.d(tag, "switchToPlayer: Already using Cast Player " + castPlayer?.deviceInfo) + return + } else { + Log.d(tag, "switchToPlayer: Switching to cast player from exo player stop exo player") + mPlayer.stop() + } + } else { + if (currentPlayer == mPlayer) { + Log.d(tag, "switchToPlayer: Already using Exo Player " + mPlayer.deviceInfo) + return + } else if (castPlayer != null) { + Log.d(tag, "switchToPlayer: Switching to exo player from cast player stop cast player") + castPlayer?.stop() + } + } + + if (currentPlaybackSession == null) { + Log.e(tag, "switchToPlayer: No Current playback session") + } else { + isSwitchingPlayer = true + } + + // Playback session in progress syncer is a copy that is up-to-date so replace current here with + // that + // TODO: bad design here implemented to prevent the session in MediaProgressSyncer from + // changing while syncing + if (mediaProgressSyncer.currentPlaybackSession != null) { + currentPlaybackSession = mediaProgressSyncer.currentPlaybackSession?.clone() + } + + currentPlayer = + if (useCastPlayer) { + Log.d(tag, "switchToPlayer: Using Cast Player " + castPlayer?.deviceInfo) + mediaSessionConnector.setPlayer(castPlayer) + playerNotificationManager.setPlayer(castPlayer) + setMediaSessionToCastVolume() + castPlayer as CastPlayer + } else { + Log.d(tag, "switchToPlayer: Using ExoPlayer") + mediaSessionConnector.setPlayer(mPlayer) + playerNotificationManager.setPlayer(mPlayer) + setMediaSessionToLocalVolume() + mPlayer + } + + clientEventEmitter?.onMediaPlayerChanged(getMediaPlayer()) + + currentPlaybackSession?.let { + Log.d(tag, "switchToPlayer: Starting new playback session ${it.displayTitle}") + if (wasPlaying) { // media is paused when switching players + clientEventEmitter?.onPlayingUpdate(false) + } + + // TODO: Start a new playback session here instead of using the existing + preparePlayer(it, false, null) + } + } + + private fun setMediaSessionToCastVolume() { + val currentVol = try { castPlayer?.getDeviceVolume() ?: 0 } catch (_: Exception) { 0 } + val provider = object : VolumeProviderCompat(VolumeProviderCompat.VOLUME_CONTROL_ABSOLUTE, 100, currentVol) { + override fun onSetVolumeTo(volume: Int) { + // Clamp, update UI immediately, then send to device + val clamped = volume.coerceIn(0, 100) + setCurrentVolume(clamped) + try { castPlayer?.setDeviceVolume(clamped) } catch (_: Exception) {} + } + + override fun onAdjustVolume(direction: Int) { + // Use Android-provided step (−1, 0, +1). Clamp, update UI immediately, then send. + val current = currentVolume + val target = (current + direction).coerceIn(0, 100) + setCurrentVolume(target) + try { castPlayer?.setDeviceVolume(target) } catch (_: Exception) {} + } + } + remoteVolumeProvider = provider + mediaSession.setPlaybackToRemote(provider) + } + + private fun setMediaSessionToLocalVolume() { + mediaSession.setPlaybackToLocal(AudioManager.STREAM_MUSIC) + remoteVolumeProvider = null + } + + fun getCurrentTrackStartOffsetMs(): Long { + return if (currentPlayer.mediaItemCount > 1) { + val windowIndex = currentPlayer.currentMediaItemIndex + val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L + currentTrackStartOffset + } else { + 0 + } + } + + fun getCurrentTime(): Long { + return currentPlayer.currentPosition + getCurrentTrackStartOffsetMs() + } + + fun getCurrentTimeSeconds(): Double { + return getCurrentTime() / 1000.0 + } + + private fun getBufferedTime(): Long { + return if (currentPlayer.mediaItemCount > 1) { + val windowIndex = currentPlayer.currentMediaItemIndex + val currentTrackStartOffset = currentPlaybackSession?.getTrackStartOffsetMs(windowIndex) ?: 0L + currentPlayer.bufferedPosition + currentTrackStartOffset + } else { + currentPlayer.bufferedPosition + } + } + + fun getBufferedTimeSeconds(): Double { + return getBufferedTime() / 1000.0 + } + + fun getDuration(): Long { + return currentPlaybackSession?.totalDurationMs ?: 0L + } + + fun getCurrentPlaybackSessionCopy(): PlaybackSession? { + return currentPlaybackSession?.clone() + } + + fun getCurrentBookChapter(): BookChapter? { + return currentPlaybackSession?.getChapterForTime(this.getCurrentTime()) + } + + fun getEndTimeOfChapterOrTrack(): Long? { + return getCurrentBookChapter()?.endMs ?: currentPlaybackSession?.getCurrentTrackEndTime() + } + + private fun getNextBookChapter(): BookChapter? { + return currentPlaybackSession?.getNextChapterForTime(this.getCurrentTime()) + } + + fun getEndTimeOfNextChapterOrTrack(): Long? { + return getNextBookChapter()?.endMs ?: currentPlaybackSession?.getNextTrackEndTime() + } + + // Called from PlayerListener play event + // check with server if progress has updated since last play and sync progress update + fun checkCurrentSessionProgress(seekBackTime: Long): Boolean { + if (currentPlaybackSession == null) return true + + mediaProgressSyncer.currentPlaybackSession?.let { playbackSession -> + if (!DeviceManager.checkConnectivity(ctx)) { + return true // carry on + } + + if (playbackSession.isLocal) { + + // Make sure this connection config exists + val serverConnectionConfig = + DeviceManager.getServerConnectionConfig(playbackSession.serverConnectionConfigId) + if (serverConnectionConfig == null) { + Log.d( + tag, + "checkCurrentSessionProgress: Local library item server connection config is not saved ${playbackSession.serverConnectionConfigId}" + ) + return true // carry on + } + + // Local playback session check if server has updated media progress + Log.d( + tag, + "checkCurrentSessionProgress: Checking if local media progress was updated on server" + ) + apiHandler.getMediaProgress( + playbackSession.libraryItemId!!, + playbackSession.episodeId, + serverConnectionConfig + ) { mediaProgress -> + if (mediaProgress != null && + mediaProgress.lastUpdate > playbackSession.updatedAt && + mediaProgress.currentTime != playbackSession.currentTime + ) { + Log.d( + tag, + "checkCurrentSessionProgress: Media progress was updated since last play time updating from ${playbackSession.currentTime} to ${mediaProgress.currentTime}" + ) + mediaProgressSyncer.syncFromServerProgress(mediaProgress) + + // Update current playback session stored in PNS since MediaProgressSyncer version is a + // copy + mediaProgressSyncer.currentPlaybackSession?.let { updatedPlaybackSession -> + currentPlaybackSession = updatedPlaybackSession + } + + Handler(Looper.getMainLooper()).post { + seekPlayer(playbackSession.currentTimeMs) + // Should already be playing + currentPlayer.volume = 1F // Volume on sleep timer might have decreased this + currentPlaybackSession?.let { mediaProgressSyncer.play(it) } + clientEventEmitter?.onPlayingUpdate(true) + } + } else { + Handler(Looper.getMainLooper()).post { + if (seekBackTime > 0L) { + seekBackward(seekBackTime) + } + + // Should already be playing + currentPlayer.volume = 1F // Volume on sleep timer might have decreased this + mediaProgressSyncer.currentPlaybackSession?.let { playbackSession -> + mediaProgressSyncer.play(playbackSession) + } + clientEventEmitter?.onPlayingUpdate(true) + } + } + } + } else { + // Streaming from server so check if playback session still exists on server + Log.d( + tag, + "checkCurrentSessionProgress: Checking if playback session ${playbackSession.id} for server stream is still available" + ) + apiHandler.getPlaybackSession(playbackSession.id) { + if (it == null) { + Log.d( + tag, + "checkCurrentSessionProgress: Playback session does not exist on server - start new playback session" + ) + + Handler(Looper.getMainLooper()).post { + currentPlayer.pause() + startNewPlaybackSession() + } + } else { + Log.d(tag, "checkCurrentSessionProgress: Playback session still available on server") + Handler(Looper.getMainLooper()).post { + if (seekBackTime > 0L) { + seekBackward(seekBackTime) + } + + currentPlayer.volume = 1F // Volume on sleep timer might have decreased this + mediaProgressSyncer.currentPlaybackSession?.let { playbackSession -> + mediaProgressSyncer.play(playbackSession) + } + + clientEventEmitter?.onPlayingUpdate(true) + } + } + } + } + } + return false + } + + fun play() { + if (currentPlayer.isPlaying) { + Log.d(tag, "Already playing") + return + } + currentPlayer.volume = 1F + currentPlayer.play() + } + + fun pause() { + currentPlayer.pause() + } + + fun playPause(): Boolean { + return if (currentPlayer.isPlaying) { + pause() + false + } else { + play() + true + } + } + + fun seekPlayer(time: Long) { + var timeToSeek = time + Log.d(tag, "seekPlayer mediaCount = ${currentPlayer.mediaItemCount} | $timeToSeek") + if (timeToSeek < 0) { + Log.w(tag, "seekPlayer invalid time $timeToSeek - setting to 0") + timeToSeek = 0L + } else if (timeToSeek > getDuration()) { + Log.w(tag, "seekPlayer invalid time $timeToSeek - setting to MAX - 2000") + timeToSeek = getDuration() - 2000L + } + + if (currentPlayer.mediaItemCount > 1) { + currentPlaybackSession?.currentTime = timeToSeek / 1000.0 + val newWindowIndex = currentPlaybackSession?.getCurrentTrackIndex() ?: 0 + val newTimeOffset = currentPlaybackSession?.getCurrentTrackTimeMs() ?: 0 + Log.d(tag, "seekPlayer seekTo $newWindowIndex | $newTimeOffset") + currentPlayer.seekTo(newWindowIndex, newTimeOffset) + } else { + currentPlayer.seekTo(timeToSeek) + } + } + + fun skipToPrevious() { + currentPlayer.seekToPrevious() + } + + fun skipToNext() { + currentPlayer.seekToNext() + } + + fun jumpForward() { + seekForward(deviceSettings.jumpForwardTimeMs) + } + + fun jumpBackward() { + seekBackward(deviceSettings.jumpBackwardsTimeMs) + } + + fun seekForward(amount: Long) { + seekPlayer(getCurrentTime() + amount) + } + + fun seekBackward(amount: Long) { + seekPlayer(getCurrentTime() - amount) + } + + fun setPlaybackSpeed(speed: Float) { + mediaManager.userSettingsPlaybackRate = speed + currentPlayer.setPlaybackSpeed(speed) + + // Refresh Android Auto actions + mediaProgressSyncer.currentPlaybackSession?.let { setMediaSessionConnectorCustomActions(it) } + } + + fun closePlayback(calledOnError: Boolean? = false) { + Log.d(tag, "closePlayback") + val config = DeviceManager.serverConnectionConfig + + val isLocal = mediaProgressSyncer.currentIsLocal + val currentSessionId = mediaProgressSyncer.currentSessionId + if (mediaProgressSyncer.listeningTimerRunning) { + Log.i(tag, "About to close playback so stopping media progress syncer first") + + mediaProgressSyncer.stop( + calledOnError == false + ) { // If closing on error then do not sync progress (causes exception) + Log.d(tag, "Media Progress syncer stopped") + // If not local session then close on server + if (!isLocal && currentSessionId != "") { + apiHandler.closePlaybackSession(currentSessionId, config) { + Log.d(tag, "Closed playback session $currentSessionId") + } + } + } + } else { + // If not local session then close on server + if (!isLocal && currentSessionId != "") { + apiHandler.closePlaybackSession(currentSessionId, config) { + Log.d(tag, "Closed playback session $currentSessionId") + } + } + } + + try { + currentPlayer.stop() + currentPlayer.clearMediaItems() + } catch (e: Exception) { + Log.e(tag, "Exception clearing exoplayer $e") + } + + currentPlaybackSession = null + mediaProgressSyncer.reset() + clientEventEmitter?.onPlaybackClosed() + + PlayerListener.lastPauseTime = 0 + isClosed = true + DeviceManager.widgetUpdater?.onPlayerClosed() + stopForeground(Service.STOP_FOREGROUND_REMOVE) + stopSelf() + } + + fun sendClientMetadata(playerState: PlayerState) { + val duration = currentPlaybackSession?.getTotalDuration() ?: 0.0 + clientEventEmitter?.onMetadata(PlaybackMetadata(duration, getCurrentTimeSeconds(), playerState)) + } + + fun getMediaPlayer(): String { + return if (currentPlayer == castPlayer) PLAYER_CAST else PLAYER_EXO + } + + @SuppressLint("HardwareIds") + fun getDeviceInfo(): DeviceInfo { + /* EXAMPLE + manufacturer: Google + model: Pixel 6 + brand: google + sdkVersion: 32 + appVersion: 0.9.46-beta + */ + val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID) + return DeviceInfo( + deviceId, + Build.MANUFACTURER, + Build.MODEL, + Build.VERSION.SDK_INT, + BuildConfig.VERSION_NAME + ) + } + + private val deviceSettings + get() = DeviceManager.deviceData.deviceSettings ?: DeviceSettings.default() + + fun getPlayItemRequestPayload(forceTranscode: Boolean): PlayItemRequestPayload { + return PlayItemRequestPayload( + getMediaPlayer(), + !forceTranscode, + forceTranscode, + getDeviceInfo() + ) + } + + fun getContext(): Context { + return ctx + } + + fun alertSyncFailing() { + clientEventEmitter?.onProgressSyncFailing() + } + + fun alertSyncSuccess() { + clientEventEmitter?.onProgressSyncSuccess() + } + + // + // MEDIA BROWSER STUFF (ANDROID AUTO) + // + private val VALID_MEDIA_BROWSERS = + mutableListOf( + "com.audiobookshelf.app", + "com.audiobookshelf.app.debug", + ANDROID_AUTO_PKG_NAME, + ANDROID_AUTO_SIMULATOR_PKG_NAME, + ANDROID_WEARABLE_PKG_NAME, + ANDROID_GSEARCH_PKG_NAME, + ANDROID_AUTOMOTIVE_PKG_NAME + ) + + private val AUTO_MEDIA_ROOT = "/" + private val LIBRARIES_ROOT = "__LIBRARIES__" + private val RECENTLY_ROOT = "__RECENTLY__" + private val DOWNLOADS_ROOT = "__DOWNLOADS__" + private val CONTINUE_ROOT = "__CONTINUE__" + private lateinit var browseTree: BrowseTree + private val browseTreeInitListeners = mutableListOf<() -> Unit>() + + private fun waitForBrowseTree(cb: () -> Unit) + { + if (this::browseTree.isInitialized) + { + cb() + } + else + { + browseTreeInitListeners += cb + } + } + + private fun onBrowseTreeInitialized() + { + // Called after browseTree is assigned for the first time + browseTreeInitListeners.forEach { it.invoke() } + browseTreeInitListeners.clear() + } + + // Only allowing android auto or similar to access media browser service + // normal loading of audiobooks is handled in webview (not natively) + private fun isValid(packageName: String, uid: Int): Boolean { + Log.d(tag, "onGetRoot: Checking package $packageName with uid $uid") + if (!VALID_MEDIA_BROWSERS.contains(packageName)) { + Log.d(tag, "onGetRoot: package $packageName not valid for the media browser service") + return false + } + return true + } + + override fun onGetRoot( + clientPackageName: String, + clientUid: Int, + rootHints: Bundle? + ): BrowserRoot? { + // Verify that the specified package is allowed to access your content + return if (!isValid(clientPackageName, clientUid)) { + // No further calls will be made to other media browsing methods. + null + } else { + AbsLogger.info(tag, "onGetRoot: clientPackageName: $clientPackageName, clientUid: $clientUid") + isStarted = true + + // Reset cache if no longer connected to server or server changed + if (mediaManager.checkResetServerItems()) { + AbsLogger.info(tag, "onGetRoot: Reset Android Auto server items cache (${DeviceManager.serverConnectionConfigString})") + forceReloadingAndroidAuto = true + } + + isAndroidAuto = true + + val extras = Bundle() + extras.putBoolean(MediaConstants.BROWSER_SERVICE_EXTRAS_KEY_SEARCH_SUPPORTED, true) + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_BROWSABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM + ) + extras.putInt( + MediaConstants.DESCRIPTION_EXTRAS_KEY_CONTENT_STYLE_PLAYABLE, + MediaConstants.DESCRIPTION_EXTRAS_VALUE_CONTENT_STYLE_LIST_ITEM + ) + + BrowserRoot(AUTO_MEDIA_ROOT, extras) + } + } + + override fun onLoadChildren( + parentMediaId: String, + result: Result> + ) { + AbsLogger.info(tag, "onLoadChildren: parentMediaId: $parentMediaId (${DeviceManager.serverConnectionConfigString})") + + result.detach() + + // Prevent crashing if app is restarted while browsing + if ((parentMediaId != DOWNLOADS_ROOT && parentMediaId != AUTO_MEDIA_ROOT) && !firstLoadDone) { + result.sendResult(null) + return + } + + if (parentMediaId == DOWNLOADS_ROOT) { // Load downloads + val localBooks = DeviceManager.dbManager.getLocalLibraryItems("book") + val localPodcasts = DeviceManager.dbManager.getLocalLibraryItems("podcast") + val localBrowseItems: MutableList = mutableListOf() + + localBooks.forEach { localLibraryItem -> + if (localLibraryItem.media.getAudioTracks().isNotEmpty()) { + val progress = DeviceManager.dbManager.getLocalMediaProgress(localLibraryItem.id) + val description = localLibraryItem.getMediaDescription(progress, ctx) + + localBrowseItems += + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + } + + localPodcasts.forEach { localLibraryItem -> + val mediaDescription = localLibraryItem.getMediaDescription(null, ctx) + localBrowseItems += + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + result.sendResult(localBrowseItems) + } else if (parentMediaId == CONTINUE_ROOT) { + val localBrowseItems: MutableList = mutableListOf() + mediaManager.serverItemsInProgress.forEach { itemInProgress -> + val progress: MediaProgressWrapper? + val mediaDescription: MediaDescriptionCompat + if (itemInProgress.episode != null) { + if (itemInProgress.isLocal) { + progress = + DeviceManager.dbManager.getLocalMediaProgress( + "${itemInProgress.libraryItemWrapper.id}-${itemInProgress.episode.id}" + ) + } else { + progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == itemInProgress.libraryItemWrapper.id && + it.episodeId == itemInProgress.episode.id + } + + // to show download icon + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId( + itemInProgress.libraryItemWrapper.id + ) + localLibraryItem?.let { lli -> + val localEpisode = + (lli.media as Podcast).episodes?.find { + it.serverEpisodeId == itemInProgress.episode.id + } + itemInProgress.episode.localEpisodeId = localEpisode?.id + } + } + mediaDescription = + itemInProgress.episode.getMediaDescription( + itemInProgress.libraryItemWrapper, + progress, + ctx + ) + } else { + if (itemInProgress.isLocal) { + progress = + DeviceManager.dbManager.getLocalMediaProgress( + itemInProgress.libraryItemWrapper.id + ) + } else { + progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == itemInProgress.libraryItemWrapper.id + } + + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId( + itemInProgress.libraryItemWrapper.id + ) + (itemInProgress.libraryItemWrapper as LibraryItem).localLibraryItemId = + localLibraryItem?.id // To show downloaded icon + } + mediaDescription = itemInProgress.libraryItemWrapper.getMediaDescription(progress, ctx) + } + localBrowseItems += + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(localBrowseItems) + } else if (parentMediaId == AUTO_MEDIA_ROOT) { + Log.d(tag, "Trying to initialize browseTree.") + if (!this::browseTree.isInitialized || forceReloadingAndroidAuto) { + forceReloadingAndroidAuto = false + AbsLogger.info(tag, "onLoadChildren: Loading Android Auto items") + mediaManager.loadAndroidAutoItems { + AbsLogger.info(tag, "onLoadChildren: Loaded Android Auto data, initializing browseTree") + + browseTree = + BrowseTree( + this, + mediaManager.serverItemsInProgress, + mediaManager.serverLibraries, + mediaManager.allLibraryPersonalizationsDone + ) + onBrowseTreeInitialized() + val children = + browseTree[parentMediaId]?.map { item -> + Log.d(tag, "Found top menu item: ${item.description.title}") + MediaBrowserCompat.MediaItem( + item.description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + result.sendResult(children as MutableList?) + firstLoadDone = true + if (mediaManager.serverLibraries.isNotEmpty()) { + AbsLogger.info(tag, "onLoadChildren: Android Auto fetching personalized data for all libraries") + mediaManager.populatePersonalizedDataForAllLibraries { + AbsLogger.info(tag, "onLoadChildren: Android Auto loaded personalized data for all libraries") + notifyChildrenChanged("/") + } + + AbsLogger.info(tag, "onLoadChildren: Android Auto fetching in progress items") + mediaManager.initializeInProgressItems { + AbsLogger.info(tag, "onLoadChildren: Android Auto loaded in progress items") + notifyChildrenChanged("/") + } + } + } + } else { + Log.d(tag, "Starting browseTree refresh") + browseTree = + BrowseTree( + this, + mediaManager.serverItemsInProgress, + mediaManager.serverLibraries, + mediaManager.allLibraryPersonalizationsDone + ) + onBrowseTreeInitialized() + val children = + browseTree[parentMediaId]?.map { item -> + Log.d(tag, "Found top menu item: ${item.description.title}") + MediaBrowserCompat.MediaItem( + item.description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + + AbsLogger.info(tag, "onLoadChildren: Android auto data loaded") + result.sendResult(children as MutableList?) + } + } else if (parentMediaId == LIBRARIES_ROOT || parentMediaId == RECENTLY_ROOT) + { + Log.d(tag, "First load done: $firstLoadDone") + if (!firstLoadDone) + { + result.sendResult(null) + return + } + + if (!this::browseTree.isInitialized) + { + // ✅ good: detach and wait for init + result.detach() + waitForBrowseTree { + val children = browseTree[parentMediaId]?.map { item -> + Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") + MediaBrowserCompat.MediaItem( + item.description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + return + } + + // Already initialized: just return + val children = browseTree[parentMediaId]?.map { item -> + Log.d(tag, "[MENU: $parentMediaId] Showing list item ${item.description.title}") + MediaBrowserCompat.MediaItem( + item.description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else if (mediaManager.getIsLibrary(parentMediaId)) { // Load library items for library + Log.d(tag, "Loading items for library $parentMediaId") + val selectedLibrary = mediaManager.getLibrary(parentMediaId) + if (selectedLibrary?.mediaType == "podcast") { // Podcasts are browseable + mediaManager.loadLibraryPodcasts(parentMediaId) { libraryItems -> + val children = + libraryItems?.map { libraryItem -> + val mediaDescription = libraryItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } else { + val children = + mutableListOf( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Authors") + .setMediaId("__LIBRARY__${parentMediaId}__AUTHORS") + .setIconUri(getUriToAbsIconDrawable(ctx, "authors")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("__LIBRARY__${parentMediaId}__SERIES_LIST") + .setIconUri(getUriToAbsIconDrawable(ctx, "columns")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ), + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Collections") + .setMediaId("__LIBRARY__${parentMediaId}__COLLECTIONS") + .setIconUri( + getUriToDrawable( + ctx, + R.drawable.md_book_multiple_outline + ) + ) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + if (mediaManager.getHasDiscovery(parentMediaId)) { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Discovery") + .setMediaId("__LIBRARY__${parentMediaId}__DISCOVERY") + .setIconUri(getUriToDrawable(ctx, R.drawable.md_telescope)) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + result.sendResult(children as MutableList?) + } + } else if (parentMediaId.startsWith(RECENTLY_ROOT)) { + Log.d(tag, "Browsing recently $parentMediaId") + val mediaIdParts = parentMediaId.split("__") + if (!mediaManager.getIsLibrary(mediaIdParts[2])) { + Log.d(tag, "${mediaIdParts[2]} is not library") + result.sendResult(null) + return + } + Log.d(tag, "Mediaparts: ${mediaIdParts.size} | $mediaIdParts") + if (mediaIdParts.size == 3) { + mediaManager.getLibraryRecentShelfs(mediaIdParts[2]) { availableShelfs -> + Log.d(tag, "Found ${availableShelfs.size} shelfs") + val children: MutableList = mutableListOf() + for (shelf in availableShelfs) { + if (shelf.type == "book") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Books") + .setMediaId("${parentMediaId}__BOOK") + .setIconUri( + getUriToDrawable( + ctx, + R.drawable.md_book_open_blank_variant_outline + ) + ) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "series") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Series") + .setMediaId("${parentMediaId}__SERIES") + .setIconUri(getUriToAbsIconDrawable(ctx, "columns")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "episode") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Episodes") + .setMediaId("${parentMediaId}__EPISODE") + .setIconUri(getUriToAbsIconDrawable(ctx, "microphone_2")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "podcast") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Podcast") + .setMediaId("${parentMediaId}__PODCAST") + .setIconUri(getUriToAbsIconDrawable(ctx, "podcast")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } else if (shelf.type == "authors") { + children.add( + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle("Authors") + .setMediaId("${parentMediaId}__AUTHORS") + .setIconUri(getUriToAbsIconDrawable(ctx, "authors")) + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + ) + } + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts.size == 4) { + mediaManager.getLibraryRecentShelfByType(mediaIdParts[2], mediaIdParts[3]) { shelf -> + if (shelf === null) { + result.sendResult(mutableListOf()) + } else { + if (shelf.type == "book") { + val children = + (shelf as LibraryShelfBookEntity).entities?.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = + libraryItem.getMediaDescription(progress, ctx, null, false) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(children as MutableList?) + } else if (shelf.type == "episode") { + val episodesWithRecentEpisode = + (shelf as LibraryShelfEpisodeEntity).entities?.filter { libraryItem -> + libraryItem.recentEpisode !== null + } + val children = + episodesWithRecentEpisode?.map { libraryItem -> + val podcast = libraryItem.media as Podcast + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.libraryId && + it.episodeId == libraryItem.recentEpisode?.id + } + + // to show download icon + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId( + libraryItem.recentEpisode!!.id + ) + localLibraryItem?.let { lli -> + val localEpisode = + (lli.media as Podcast).episodes?.find { + it.serverEpisodeId == libraryItem.recentEpisode.id + } + libraryItem.recentEpisode.localEpisodeId = localEpisode?.id + } + + val description = + libraryItem.recentEpisode.getMediaDescription( + libraryItem, + progress, + ctx + ) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(children as MutableList?) + } else if (shelf.type == "podcast") { + val children = + (shelf as LibraryShelfPodcastEntity).entities?.map { libraryItem -> + val mediaDescription = libraryItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + mediaDescription, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else if (shelf.type == "series") { + val children = + (shelf as LibraryShelfSeriesEntity).entities?.map { librarySeriesItem -> + val description = librarySeriesItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else if (shelf.type == "authors") { + val children = + (shelf as LibraryShelfAuthorEntity).entities?.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + result.sendResult(mutableListOf()) + } + } + } + } + } else if (parentMediaId.startsWith("__LIBRARY__")) { + Log.d(tag, "Browsing library $parentMediaId") + val mediaIdParts = parentMediaId.split("__") + /* + MediaIdParts for Library + 1: LIBRARY + 2: mediaId for library + 3: Browsing style (AUTHORS, AUTHOR, AUTHOR_SERIES, SERIES_LIST, SERIES, COLLECTION, COLLECTIONS, DISCOVERY) + 4: + - Paging: SERIES_LIST, AUTHORS + - SeriesId: SERIES + - AuthorId: AUTHOR, AUTHOR_SERIES + - CollectionId: COLLECTIONS + 5: SeriesId: AUTHOR_SERIES + */ + if (!mediaManager.getIsLibrary(mediaIdParts[2])) { + Log.d(tag, "${mediaIdParts[2]} is not library") + result.sendResult(null) + return + } + Log.d(tag, "$mediaIdParts") + if (mediaIdParts[3] == "SERIES_LIST" && mediaIdParts.size == 5) { + Log.d(tag, "Loading series from library ${mediaIdParts[2]} with paging ${mediaIdParts[4]}") + mediaManager.loadLibrarySeriesWithAudio(mediaIdParts[2], mediaIdParts[4]) { seriesItems -> + Log.d(tag, "Received ${seriesItems.size} series") + + val seriesLetters = + seriesItems + .groupingBy { iwb -> + iwb.title.substring(0, mediaIdParts[4].length + 1).uppercase() + } + .eachCount() + if (seriesItems.size > + DeviceManager.deviceData.deviceSettings!! + .androidAutoBrowseLimitForGrouping && + seriesItems.size > 1 && + seriesLetters.size > 1 + ) { + val children = + seriesLetters.map { (seriesLetter, seriesCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(seriesLetter) + .setMediaId("${parentMediaId}${seriesLetter.last()}") + .setSubtitle("$seriesCount series") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = + seriesItems.map { seriesItem -> + val description = seriesItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "SERIES_LIST") { + Log.d(tag, "Loading series from library ${mediaIdParts[2]}") + mediaManager.loadLibrarySeriesWithAudio(mediaIdParts[2]) { seriesItems -> + Log.d(tag, "Received ${seriesItems.size} series") + if (seriesItems.size > + DeviceManager.deviceData.deviceSettings!! + .androidAutoBrowseLimitForGrouping && seriesItems.size > 1 + ) { + val seriesLetters = + seriesItems.groupingBy { iwb -> iwb.title.first().uppercaseChar() }.eachCount() + val children = + seriesLetters.map { (seriesLetter, seriesCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(seriesLetter.toString()) + .setSubtitle("$seriesCount series") + .setMediaId("${parentMediaId}__${seriesLetter}") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = + seriesItems.map { seriesItem -> + val description = seriesItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "SERIES") { + Log.d(tag, "Loading items for serie ${mediaIdParts[4]} from library ${mediaIdParts[2]}") + mediaManager.loadLibrarySeriesItemsWithAudio(mediaIdParts[2], mediaIdParts[4]) { + libraryItems -> + Log.d(tag, "Received ${libraryItems.size} library items") + var items = libraryItems + if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseSeriesSequenceOrder === + AndroidAutoBrowseSeriesSequenceOrderSetting.DESC + ) { + items = libraryItems.reversed() + } + val children = + items.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, true) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "AUTHORS" && mediaIdParts.size == 5) { + Log.d(tag, "Loading authors from library ${mediaIdParts[2]} with paging ${mediaIdParts[4]}") + mediaManager.loadAuthorsWithBooks(mediaIdParts[2], mediaIdParts[4]) { authorItems -> + Log.d(tag, "Received ${authorItems.size} authors") + + val authorLetters = + authorItems + .groupingBy { iwb -> + iwb.name.substring(0, mediaIdParts[4].length + 1).uppercase() + } + .eachCount() + if (authorItems.size > + DeviceManager.deviceData.deviceSettings!! + .androidAutoBrowseLimitForGrouping && + authorItems.size > 1 && + authorLetters.size > 1 + ) { + val children = + authorLetters.map { (authorLetter, authorCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(authorLetter) + .setMediaId("${parentMediaId}${authorLetter.last()}") + .setSubtitle("$authorCount authors") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = + authorItems.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "AUTHORS") { + Log.d(tag, "Loading authors from library ${mediaIdParts[2]}") + mediaManager.loadAuthorsWithBooks(mediaIdParts[2]) { authorItems -> + Log.d(tag, "Received ${authorItems.size} authors") + if (authorItems.size > + DeviceManager.deviceData.deviceSettings!! + .androidAutoBrowseLimitForGrouping && authorItems.size > 1 + ) { + val authorLetters = + authorItems.groupingBy { iwb -> iwb.name.first().uppercaseChar() }.eachCount() + val children = + authorLetters.map { (authorLetter, authorCount) -> + MediaBrowserCompat.MediaItem( + MediaDescriptionCompat.Builder() + .setTitle(authorLetter.toString()) + .setSubtitle("$authorCount authors") + .setMediaId("${parentMediaId}__${authorLetter}") + .build(), + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } else { + val children = + authorItems.map { authorItem -> + val description = authorItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } + } else if (mediaIdParts[3] == "AUTHOR") { + mediaManager.loadAuthorBooksWithAudio(mediaIdParts[2], mediaIdParts[4]) { libraryItems -> + val children = + libraryItems.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + if (libraryItem.collapsedSeries != null) { + val description = + libraryItem.getMediaDescription(progress, ctx, mediaIdParts[4]) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } else { + val description = libraryItem.getMediaDescription(progress, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "AUTHOR_SERIES") { + mediaManager.loadAuthorSeriesBooksWithAudio( + mediaIdParts[2], + mediaIdParts[4], + mediaIdParts[5] + ) { libraryItems -> + var items = libraryItems + if (DeviceManager.deviceData.deviceSettings!!.androidAutoBrowseSeriesSequenceOrder === + AndroidAutoBrowseSeriesSequenceOrderSetting.DESC + ) { + items = libraryItems.reversed() + } + val children = + items.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx, null, true) + if (libraryItem.collapsedSeries != null) { + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } else { + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "COLLECTIONS") { + Log.d(tag, "Loading collections from library ${mediaIdParts[2]}") + mediaManager.loadLibraryCollectionsWithAudio(mediaIdParts[2]) { collectionItems -> + Log.d(tag, "Received ${collectionItems.size} collections") + val children = + collectionItems.map { collectionItem -> + val description = collectionItem.getMediaDescription(null, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_BROWSABLE + ) + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "COLLECTION") { + Log.d(tag, "Loading collection ${mediaIdParts[4]} books from library ${mediaIdParts[2]}") + mediaManager.loadLibraryCollectionBooksWithAudio(mediaIdParts[2], mediaIdParts[4]) { + libraryItems -> + Log.d(tag, "Received ${libraryItems.size} collections") + val children = + libraryItems.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(children as MutableList?) + } + } else if (mediaIdParts[3] == "DISCOVERY") { + Log.d(tag, "Loading discovery from library ${mediaIdParts[2]}") + mediaManager.loadLibraryDiscoveryBooksWithAudio(mediaIdParts[2]) { libraryItems -> + Log.d(tag, "Received ${libraryItems.size} libraryItems for discovery") + val children = + libraryItems.map { libraryItem -> + val progress = + mediaManager.serverUserMediaProgress.find { + it.libraryItemId == libraryItem.id + } + val localLibraryItem = + DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItem.id) + libraryItem.localLibraryItemId = localLibraryItem?.id + val description = libraryItem.getMediaDescription(progress, ctx) + MediaBrowserCompat.MediaItem( + description, + MediaBrowserCompat.MediaItem.FLAG_PLAYABLE + ) + } + result.sendResult(children as MutableList?) + } + } else { + result.sendResult(null) + } + } else { + Log.d(tag, "Loading podcast episodes for podcast $parentMediaId") + mediaManager.loadPodcastEpisodeMediaBrowserItems(parentMediaId, ctx) { result.sendResult(it) } + } + } + + override fun onSearch( + query: String, + extras: Bundle?, + result: Result> + ) { + result.detach() + if (cachedSearch != query) { + Log.d(tag, "Search bundle: $extras") + var foundBooks: MutableList = mutableListOf() + var foundPodcasts: MutableList = mutableListOf() + var foundSeries: MutableList = mutableListOf() + var foundAuthors: MutableList = mutableListOf() + + mediaManager.serverLibraries.forEach { serverLibrary -> + runBlocking { + // Skip searching library if it doesn't have any audio files + if (serverLibrary.stats?.numAudioFiles == 0) return@runBlocking + val searchResult = mediaManager.doSearch(serverLibrary.id, query) + for (resultData in searchResult.entries.iterator()) { + when (resultData.key) { + "book" -> foundBooks.addAll(resultData.value) + "series" -> foundSeries.addAll(resultData.value) + "authors" -> foundAuthors.addAll(resultData.value) + "podcast" -> foundPodcasts.addAll(resultData.value) + } + } + } + } + foundBooks.addAll(foundSeries) + foundBooks.addAll(foundAuthors) + cachedSearchResults = foundBooks + } + result.sendResult(cachedSearchResults) + cachedSearch = query + Log.d(tag, "onSearch: Done") + } + + // + // SHAKE SENSOR + // + private fun initSensor() { + // ShakeDetector initialization + mSensorManager = getSystemService(SENSOR_SERVICE) as SensorManager + mAccelerometer = mSensorManager!!.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + + mShakeDetector = ShakeDetector() + mShakeDetector!!.setOnShakeListener( + object : ShakeDetector.OnShakeListener { + override fun onShake(count: Int) { + Log.d(tag, "PHONE SHAKE! $count") + sleepTimerManager.handleShake() + } + } + ) + } + + // Shake sensor used for sleep timer + fun registerSensor() { + if (isShakeSensorRegistered) { + Log.i(tag, "Shake sensor already registered") + return + } + shakeSensorUnregisterTask?.cancel() + + Log.d(tag, "Registering shake SENSOR ${mAccelerometer?.isWakeUpSensor}") + val success = + mSensorManager!!.registerListener( + mShakeDetector, + mAccelerometer, + SensorManager.SENSOR_DELAY_UI + ) + if (success) isShakeSensorRegistered = true + } + + fun unregisterSensor() { + if (!isShakeSensorRegistered) return + + // Unregister shake sensor after wake up expiration + shakeSensorUnregisterTask?.cancel() + shakeSensorUnregisterTask = + Timer("ShakeUnregisterTimer", false).schedule(SLEEP_TIMER_WAKE_UP_EXPIRATION) { + Handler(Looper.getMainLooper()).post { + Log.d(tag, "wake time expired: Unregistering shake sensor") + mSensorManager!!.unregisterListener(mShakeDetector) + isShakeSensorRegistered = false + } + } + } + + private val networkCallback = + object : ConnectivityManager.NetworkCallback() { + // Network capabilities have changed for the network + override fun onCapabilitiesChanged( + network: Network, + networkCapabilities: NetworkCapabilities + ) { + super.onCapabilitiesChanged(network, networkCapabilities) + + isUnmeteredNetwork = + networkCapabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_NOT_METERED + ) + hasNetworkConnectivity = + networkCapabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_VALIDATED + ) && + networkCapabilities.hasCapability( + NetworkCapabilities.NET_CAPABILITY_INTERNET + ) + Log.i( + tag, + "Network capabilities changed. hasNetworkConnectivity=$hasNetworkConnectivity | isUnmeteredNetwork=$isUnmeteredNetwork" + ) + clientEventEmitter?.onNetworkMeteredChanged(isUnmeteredNetwork) + if (hasNetworkConnectivity) { + // Force android auto loading if libraries are empty. + // Lack of network connectivity is most likely reason for libraries being empty + if (isBrowseTreeInitialized() && + firstLoadDone && + mediaManager.serverLibraries.isEmpty() + ) { + forceReloadingAndroidAuto = true + notifyChildrenChanged("/") + } + } + } + } + + inner class JumpBackwardCustomActionProvider : CustomActionProvider { + override fun onCustomAction(player: Player, action: String, extras: Bundle?) { + /* + This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is + responsible to reacting to a custom action. + */ + } + + override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_JUMP_BACKWARD, + getContext().getString(R.string.action_jump_backward), + R.drawable.exo_icon_rewind + ) + .build() + } + } + + inner class JumpForwardCustomActionProvider : CustomActionProvider { + override fun onCustomAction(player: Player, action: String, extras: Bundle?) { + /* + This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is + responsible to reacting to a custom action. + */ + } + + override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_JUMP_FORWARD, + getContext().getString(R.string.action_jump_forward), + R.drawable.exo_icon_fastforward + ) + .build() + } + } + + inner class SkipForwardCustomActionProvider : CustomActionProvider { + override fun onCustomAction(player: Player, action: String, extras: Bundle?) { + /* + This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is + responsible to reacting to a custom action. + */ + } + + override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_SKIP_FORWARD, + getContext().getString(R.string.action_skip_forward), + R.drawable.skip_next_24 + ) + .build() + } + } + + inner class SkipBackwardCustomActionProvider : CustomActionProvider { + override fun onCustomAction(player: Player, action: String, extras: Bundle?) { + /* + This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is + responsible to reacting to a custom action. + */ + } + + override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_SKIP_BACKWARD, + getContext().getString(R.string.action_skip_backward), + R.drawable.skip_previous_24 + ) + .build() + } + } + + inner class ChangePlaybackSpeedCustomActionProvider : CustomActionProvider { + override fun onCustomAction(player: Player, action: String, extras: Bundle?) { + /* + This does not appear to ever get called. Instead, MediaSessionCallback.onCustomAction() is + responsible to reacting to a custom action. + */ + } + + override fun getCustomAction(player: Player): PlaybackStateCompat.CustomAction? { + val playbackRate = mediaManager.getSavedPlaybackRate() + + // Rounding values in the event a non preset value (.5, 1, 1.2, 1.5, 2, 3) is selected in the + // phone app + val drawable: Int = + when (playbackRate) { + in 0.5f..0.7f -> R.drawable.ic_play_speed_0_5x + in 0.8f..1.0f -> R.drawable.ic_play_speed_1_0x + in 1.1f..1.3f -> R.drawable.ic_play_speed_1_2x + in 1.4f..1.7f -> R.drawable.ic_play_speed_1_5x + in 1.8f..2.4f -> R.drawable.ic_play_speed_2_0x + in 2.5f..3.0f -> R.drawable.ic_play_speed_3_0x + // anything set above 3 will be show the 3x to save from creating 100 icons + else -> R.drawable.ic_play_speed_3_0x + } + val customActionExtras = Bundle() + customActionExtras.putFloat("speed", playbackRate) + return PlaybackStateCompat.CustomAction.Builder( + CUSTOM_ACTION_CHANGE_SPEED, + getContext().getString(R.string.action_change_speed), + drawable + ) + .setExtras(customActionExtras) + .build() + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/player/ShakeDetector.kt b/android/app/src/main/java/com/audiobookshelf/app/player/ShakeDetector.kt new file mode 100644 index 0000000..d25206d --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/player/ShakeDetector.kt @@ -0,0 +1,70 @@ +package com.audiobookshelf.app.player + +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.plugins.AbsLogger +import kotlin.math.sqrt + +class ShakeDetector : SensorEventListener { + private var mListener: OnShakeListener? = null + private var mShakeTimestamp: Long = 0 + private var mShakeCount = 0 + fun setOnShakeListener(listener: OnShakeListener?) { + mListener = listener + } + + interface OnShakeListener { + fun onShake(count: Int) + } + + override fun onAccuracyChanged( + sensor: Sensor, + accuracy: Int + ) { // ignore + } + + override fun onSensorChanged(event: SensorEvent) { + if (mListener != null) { + val x = event.values[0] + val y = event.values[1] + val z = event.values[2] + val gX = x / SensorManager.GRAVITY_EARTH + val gY = y / SensorManager.GRAVITY_EARTH + val gZ = z / SensorManager.GRAVITY_EARTH + // gForce will be close to 1 when there is no movement. + val gForce: Float = sqrt(gX * gX + gY * gY + gZ * gZ) + val shakeThreshold = DeviceManager.deviceData.deviceSettings?.getShakeThresholdGravity() ?: 1.6f + if (gForce > shakeThreshold) { + val now = System.currentTimeMillis() + // ignore shake events too close to each other (500ms) + if (mShakeTimestamp + SHAKE_SLOP_TIME_MS > now) { + return + } + // reset the shake count after 3 seconds of no shakes + if (mShakeTimestamp + SHAKE_COUNT_RESET_TIME_MS < now) { + mShakeCount = 0 + } + AbsLogger.info("ShakeDetector", "Device shake above threshold ($gForce > $shakeThreshold)") + mShakeTimestamp = now + mShakeCount++ + mListener!!.onShake(mShakeCount) + } + } + } + + companion object { + /* + * The gForce that is necessary to register as shake. + * Must be greater than 1G (one earth gravity unit). + * You can install "G-Force", by Blake La Pierre + * from the Google Play Store and run it to see how + * many G's it takes to register a shake + */ +// var SHAKE_THRESHOLD_GRAVITY = 1.5f // orig 2.7f + private const val SHAKE_SLOP_TIME_MS = 500 + private const val SHAKE_COUNT_RESET_TIME_MS = 3000 + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt new file mode 100644 index 0000000..6e76637 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsAudioPlayer.kt @@ -0,0 +1,461 @@ +package com.audiobookshelf.app.plugins + +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaEventManager +import com.audiobookshelf.app.player.CastManager +import com.audiobookshelf.app.player.PlayerListener +import com.audiobookshelf.app.player.PlayerNotificationService +import com.audiobookshelf.app.server.ApiHandler +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.* +import com.getcapacitor.annotation.CapacitorPlugin +import com.google.android.gms.cast.CastDevice +import com.google.android.gms.common.ConnectionResult +import com.google.android.gms.common.GoogleApiAvailability +import org.json.JSONObject + +@CapacitorPlugin(name = "AbsAudioPlayer") +class AbsAudioPlayer : Plugin() { + private val tag = "AbsAudioPlayer" + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + private lateinit var mainActivity: MainActivity + private lateinit var apiHandler:ApiHandler + var castManager:CastManager? = null + + lateinit var playerNotificationService: PlayerNotificationService + + private var isCastAvailable:Boolean = false + + // Track foreground state to avoid flooding WebView with events while backgrounded + private var isInForeground: Boolean = true + + override fun load() { + mainActivity = (activity as MainActivity) + apiHandler = ApiHandler(mainActivity) + + try { + initCastManager() + } catch(e:Exception) { + Log.e(tag, "initCastManager exception ${e.printStackTrace()}") + } + + val foregroundServiceReady : () -> Unit = { + playerNotificationService = mainActivity.foregroundService + + playerNotificationService.clientEventEmitter = (object : PlayerNotificationService.ClientEventEmitter { + override fun onPlaybackSession(playbackSession: PlaybackSession) { + notifyListeners("onPlaybackSession", JSObject(jacksonMapper.writeValueAsString(playbackSession))) + } + + override fun onPlaybackClosed() { + emit("onPlaybackClosed", true) + } + + override fun onPlayingUpdate(isPlaying: Boolean) { + emit("onPlayingUpdate", isPlaying) + } + + override fun onMetadata(metadata: PlaybackMetadata) { + // Skip frequent metadata updates when app is backgrounded to prevent event queue buildup + if (!isInForeground) return + notifyListeners("onMetadata", JSObject(jacksonMapper.writeValueAsString(metadata))) + } + + override fun onSleepTimerEnded(currentPosition: Long) { + emit("onSleepTimerEnded", currentPosition) + } + + override fun onSleepTimerSet(sleepTimeRemaining: Int, isAutoSleepTimer:Boolean) { + // Skip sleep timer updates when app is backgrounded to prevent event queue buildup + if (!isInForeground) return + val ret = JSObject() + ret.put("value", sleepTimeRemaining) + ret.put("isAuto", isAutoSleepTimer) + notifyListeners("onSleepTimerSet", ret) + } + + override fun onLocalMediaProgressUpdate(localMediaProgress: LocalMediaProgress) { + // Skip progress updates when app is backgrounded to prevent event queue buildup + if (!isInForeground) return + notifyListeners("onLocalMediaProgressUpdate", JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) + } + + override fun onPlaybackFailed(errorMessage: String) { + emit("onPlaybackFailed", errorMessage) + } + + override fun onMediaPlayerChanged(mediaPlayer:String) { + emit("onMediaPlayerChanged", mediaPlayer) + } + + override fun onProgressSyncFailing() { + emit("onProgressSyncFailing", "") + } + + override fun onProgressSyncSuccess() { + emit("onProgressSyncSuccess", "") + } + + override fun onNetworkMeteredChanged(isUnmetered:Boolean) { + emit("onNetworkMeteredChanged", isUnmetered) + } + + override fun onMediaItemHistoryUpdated(mediaItemHistory:MediaItemHistory) { + notifyListeners("onMediaItemHistoryUpdated", JSObject(jacksonMapper.writeValueAsString(mediaItemHistory))) + } + + override fun onPlaybackSpeedChanged(playbackSpeed:Float) { + emit("onPlaybackSpeedChanged", playbackSpeed) + } + }) + + MediaEventManager.clientEventEmitter = playerNotificationService.clientEventEmitter + } + mainActivity.pluginCallback = foregroundServiceReady + } + + fun emit(evtName: String, value: Any) { + val ret = JSObject() + ret.put("value", value) + notifyListeners(evtName, ret) + } + + override fun handleOnDestroy() { + castManager?.detach() + super.handleOnDestroy() + } + + override fun handleOnPause() { + super.handleOnPause() + isInForeground = false + } + + override fun handleOnResume() { + super.handleOnResume() + isInForeground = true + + // Send current state to UI after resume to sync up (with small delay to let WebView fully resume) + if (::playerNotificationService.isInitialized && playerNotificationService.currentPlaybackSession != null) { + Handler(Looper.getMainLooper()).postDelayed({ + playerNotificationService.sendClientMetadata(PlayerState.READY) + playerNotificationService.sleepTimerManager.sendCurrentSleepTimerState() + playerNotificationService.mediaProgressSyncer.currentLocalMediaProgress?.let { + playerNotificationService.clientEventEmitter?.onLocalMediaProgressUpdate(it) + } + }, 100) + } + } + + private fun initCastManager() { + val googleApi = GoogleApiAvailability.getInstance() + val statusCode = googleApi.isGooglePlayServicesAvailable(mainActivity) + + if (statusCode != ConnectionResult.SUCCESS) { + if (statusCode == ConnectionResult.SERVICE_MISSING) { + Log.w(tag, "initCastManager: Google Api Missing") + } else if (statusCode == ConnectionResult.SERVICE_DISABLED) { + Log.w(tag, "initCastManager: Google Api Disabled") + } else if (statusCode == ConnectionResult.SERVICE_INVALID) { + Log.w(tag, "initCastManager: Google Api Invalid") + } else if (statusCode == ConnectionResult.SERVICE_UPDATING) { + Log.w(tag, "initCastManager: Google Api Updating") + } else if (statusCode == ConnectionResult.SERVICE_VERSION_UPDATE_REQUIRED) { + Log.w(tag, "initCastManager: Google Api Update Required") + } + return + } + + val connListener = object: CastManager.ChromecastListener() { + override fun onReceiverAvailableUpdate(available: Boolean) { + Log.d(tag, "ChromecastListener: CAST Receiver Update Available $available") + isCastAvailable = available + emit("onCastAvailableUpdate", available) + } + + override fun onSessionRejoin(jsonSession: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onSessionRejoin") + } + + override fun onMediaLoaded(jsonMedia: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onMediaLoaded") + } + + override fun onMediaUpdate(jsonMedia: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onMediaUpdate") + } + + override fun onSessionUpdate(jsonSession: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onSessionUpdate") + } + + override fun onSessionEnd(jsonSession: JSONObject?) { + Log.d(tag, "ChromecastListener: CAST onSessionEnd") + } + + override fun onMessageReceived(p0: CastDevice, p1: String, p2: String) { + Log.d(tag, "ChromecastListener: CAST onMessageReceived") + } + } + + castManager = CastManager(mainActivity) + castManager?.startRouteScan(connListener) + } + + @PluginMethod + fun prepareLibraryItem(call: PluginCall) { + val libraryItemId = call.getString("libraryItemId", "").toString() + val episodeId = call.getString("episodeId", "").toString() + val playWhenReady = call.getBoolean("playWhenReady") == true + val playbackRate = call.getFloat("playbackRate",1f) ?: 1f + val startTimeOverride = call.getDouble("startTime") + + AbsLogger.info("AbsAudioPlayer", "prepareLibraryItem: lid=$libraryItemId, startTimeOverride=$startTimeOverride, playbackRate=$playbackRate") + + if (libraryItemId.isEmpty()) { + Log.e(tag, "Invalid call to play library item no library item id") + return call.resolve(JSObject("{\"error\":\"Invalid request\"}")) + } + + if (libraryItemId.startsWith("local")) { // Play local media item + DeviceManager.dbManager.getLocalLibraryItem(libraryItemId)?.let { + var episode: PodcastEpisode? = null + if (episodeId.isNotEmpty()) { + val podcastMedia = it.media as Podcast + episode = podcastMedia.episodes?.find { ep -> ep.id == episodeId } + if (episode == null) { + Log.e(tag, "prepareLibraryItem: Podcast episode not found $episodeId") + return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}")) + } + } + if (!it.hasTracks(mainActivity, episode)) { + return call.resolve(JSObject("{\"error\":\"No audio files found on device. Download book again to fix.\"}")) + } + + Handler(Looper.getMainLooper()).post { + Log.d(tag, "prepareLibraryItem: Preparing Local Media item ${jacksonMapper.writeValueAsString(it)}") + val playbackSession = it.getPlaybackSession(episode, playerNotificationService.getDeviceInfo()) + if (startTimeOverride != null) { + Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride") + playbackSession.currentTime = startTimeOverride + } + + if (playerNotificationService.mediaProgressSyncer.listeningTimerRunning) { // If progress syncing then first stop before preparing next + playerNotificationService.mediaProgressSyncer.stop { + Log.d(tag, "Media progress syncer was already syncing - stopped") + PlayerListener.lazyIsPlaying = false + + Handler(Looper.getMainLooper()).post { // TODO: This was needed again which is probably a design a flaw + playerNotificationService.preparePlayer( + playbackSession, + playWhenReady, + playbackRate + ) + } + } + } else { + playerNotificationService.mediaProgressSyncer.reset() + playerNotificationService.preparePlayer(playbackSession, playWhenReady, playbackRate) + } + } + return call.resolve(JSObject()) + } + } else { // Play library item from server + val playItemRequestPayload = playerNotificationService.getPlayItemRequestPayload(false) + Handler(Looper.getMainLooper()).post { + playerNotificationService.mediaProgressSyncer.stop { + apiHandler.playLibraryItem(libraryItemId, episodeId, playItemRequestPayload) { + if (it == null) { + call.resolve(JSObject("{\"error\":\"Server play request failed\"}")) + } else { + if (startTimeOverride != null) { + Log.d(tag, "prepareLibraryItem: Using start time override $startTimeOverride") + it.currentTime = startTimeOverride + } + + Handler(Looper.getMainLooper()).post { + Log.d(tag, "Preparing Player playback session ${jacksonMapper.writeValueAsString(it)}") + PlayerListener.lazyIsPlaying = false + playerNotificationService.preparePlayer(it, playWhenReady, playbackRate) + } + call.resolve(JSObject(jacksonMapper.writeValueAsString(it))) + } + } + } + } + } + } + + @PluginMethod + fun getCurrentTime(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + val currentTime = playerNotificationService.getCurrentTimeSeconds() + val bufferedTime = playerNotificationService.getBufferedTimeSeconds() + val ret = JSObject() + ret.put("value", currentTime) + ret.put("bufferedTime", bufferedTime) + call.resolve(ret) + } + } + + @PluginMethod + fun pausePlayer(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + playerNotificationService.pause() + call.resolve() + } + } + + @PluginMethod + fun playPlayer(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + playerNotificationService.play() + call.resolve() + } + } + + @PluginMethod + fun playPause(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + val playing = playerNotificationService.playPause() + call.resolve(JSObject("{\"playing\":$playing}")) + } + } + + @PluginMethod + fun seek(call: PluginCall) { + val time: Double = call.getDouble("value", 0.0) ?: 0.0 // Value in seconds, fractional + Log.d(tag, "seek action to $time") + Handler(Looper.getMainLooper()).post { + playerNotificationService.seekPlayer((time * 1000L).toLong()) + call.resolve() + } + } + + @PluginMethod + fun seekForward(call: PluginCall) { + val amount:Int = call.getInt("value", 0) ?: 0 + Handler(Looper.getMainLooper()).post { + playerNotificationService.seekForward(amount * 1000L) // convert to ms + call.resolve() + } + } + + @PluginMethod + fun seekBackward(call: PluginCall) { + val amount:Int = call.getInt("value", 0) ?: 0 // Value in seconds + Handler(Looper.getMainLooper()).post { + playerNotificationService.seekBackward(amount * 1000L) // convert to ms + call.resolve() + } + } + + @PluginMethod + fun setPlaybackSpeed(call: PluginCall) { + val playbackSpeed:Float = call.getFloat("value", 1.0f) ?: 1.0f + + Handler(Looper.getMainLooper()).post { + playerNotificationService.setPlaybackSpeed(playbackSpeed) + call.resolve() + } + } + + @PluginMethod + fun closePlayback(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + playerNotificationService.closePlayback() + call.resolve() + } + } + + @PluginMethod + fun setSleepTimer(call: PluginCall) { + val time:Long = call.getString("time", "360000")!!.toLong() + val isChapterTime:Boolean = call.getBoolean("isChapterTime", false) == true + + Handler(Looper.getMainLooper()).post { + val playbackSession: PlaybackSession? = playerNotificationService.mediaProgressSyncer.currentPlaybackSession ?: playerNotificationService.currentPlaybackSession + val success:Boolean = playerNotificationService.sleepTimerManager.setManualSleepTimer(playbackSession?.id ?: "", time, isChapterTime) + val ret = JSObject() + ret.put("success", success) + call.resolve(ret) + } + } + + @PluginMethod + fun getSleepTimerTime(call: PluginCall) { + val time = playerNotificationService.sleepTimerManager.getSleepTimerTime() + val ret = JSObject() + ret.put("value", time) + call.resolve(ret) + } + + @PluginMethod + fun increaseSleepTime(call: PluginCall) { + val time:Long = call.getString("time", "300000")!!.toLong() + + Handler(Looper.getMainLooper()).post { + playerNotificationService.sleepTimerManager.increaseSleepTime(time) + val ret = JSObject() + ret.put("success", true) + call.resolve() + } + } + + @PluginMethod + fun decreaseSleepTime(call: PluginCall) { + val time:Long = call.getString("time", "300000")!!.toLong() + + Handler(Looper.getMainLooper()).post { + playerNotificationService.sleepTimerManager.decreaseSleepTime(time) + val ret = JSObject() + ret.put("success", true) + call.resolve() + } + } + + @PluginMethod + fun cancelSleepTimer(call: PluginCall) { + Handler(Looper.getMainLooper()).post { + playerNotificationService.sleepTimerManager.cancelSleepTimer() + } + call.resolve() + } + + @PluginMethod + fun requestSession(call: PluginCall) { + // Need to make sure the player service has been started + Log.d(tag, "CAST REQUEST SESSION PLUGIN") + call.resolve() + if (castManager == null) { + Log.e(tag, "Cast Manager not initialized") + return + } + castManager?.requestSession(playerNotificationService, object : CastManager.RequestSessionCallback() { + override fun onError(errorCode: Int) { + Log.e(tag, "CAST REQUEST SESSION CALLBACK ERROR $errorCode") + } + + override fun onCancel() { + Log.d(tag, "CAST REQUEST SESSION ON CANCEL") + } + + override fun onJoin(jsonSession: JSONObject?) { + Log.d(tag, "CAST REQUEST SESSION ON JOIN") + } + }) + } + + @PluginMethod + fun getIsCastAvailable(call: PluginCall) { + val jsobj = JSObject() + jsobj.put("value", isCastAvailable) + call.resolve(jsobj) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt new file mode 100644 index 0000000..0964d47 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDatabase.kt @@ -0,0 +1,591 @@ +package com.audiobookshelf.app.plugins + +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaEventManager +import com.audiobookshelf.app.server.ApiHandler +import com.audiobookshelf.app.managers.SecureStorage +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.getcapacitor.* +import com.getcapacitor.annotation.CapacitorPlugin +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@CapacitorPlugin(name = "AbsDatabase") +class AbsDatabase : Plugin() { + val tag = "AbsDatabase" + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + private lateinit var mainActivity: MainActivity + private lateinit var apiHandler: ApiHandler + private lateinit var secureStorage: SecureStorage + + data class LocalMediaProgressPayload(val value:List) + data class LocalLibraryItemsPayload(val value:List) + data class LocalFoldersPayload(val value:List) + data class ServerConnConfigPayload(val id:String?, val index:Int, val name:String?, val userId:String, val username:String, var version:String, val token:String, val refreshToken:String?, val address:String?, val customHeaders:Map?) + + override fun load() { + mainActivity = (activity as MainActivity) + apiHandler = ApiHandler(mainActivity) + ApiHandler.absDatabaseNotifyListeners = ::notifyListeners + + secureStorage = SecureStorage(mainActivity) + + DeviceManager.dbManager.cleanLocalMediaProgress() + DeviceManager.dbManager.cleanLocalLibraryItems(mainActivity) + DeviceManager.dbManager.cleanLogs() + } + + @PluginMethod + fun getDeviceData(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + val deviceData = DeviceManager.dbManager.getDeviceData() + call.resolve(JSObject(jacksonMapper.writeValueAsString(deviceData))) + } + } + + @PluginMethod + fun getLocalFolders(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + val folders = DeviceManager.dbManager.getAllLocalFolders() + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalFoldersPayload(folders)))) + } + } + + @PluginMethod + fun getLocalFolder(call:PluginCall) { + val folderId = call.getString("folderId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + DeviceManager.dbManager.getLocalFolder(folderId)?.let { + val folderObj = jacksonMapper.writeValueAsString(it) + call.resolve(JSObject(folderObj)) + } ?: call.resolve() + } + } + + @PluginMethod + fun getLocalLibraryItem(call:PluginCall) { + val id = call.getString("id", "").toString() + + GlobalScope.launch(Dispatchers.IO) { + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(id) + if (localLibraryItem == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) + } + } + } + + @PluginMethod + fun getLocalLibraryItemByLId(call:PluginCall) { + val libraryItemId = call.getString("libraryItemId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItemByLId(libraryItemId) + if (localLibraryItem == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) + } + } + } + + @PluginMethod + fun getLocalLibraryItems(call:PluginCall) { + val mediaType = call.getString("mediaType", "").toString() + + GlobalScope.launch(Dispatchers.IO) { + val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItems(mediaType) + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) + } + } + + @PluginMethod + fun getLocalLibraryItemsInFolder(call:PluginCall) { + val folderId = call.getString("folderId", "").toString() + GlobalScope.launch(Dispatchers.IO) { + val localLibraryItems = DeviceManager.dbManager.getLocalLibraryItemsInFolder(folderId) + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalLibraryItemsPayload(localLibraryItems)))) + } + } + + @PluginMethod + fun setCurrentServerConnectionConfig(call:PluginCall) { + Log.d(tag, "setCurrentServerConnectionConfig called") + val serverConfigPayload = jacksonMapper.readValue(call.data.toString()) + var serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConfigPayload.id } + + val userId = serverConfigPayload.userId + val username = serverConfigPayload.username + val serverVersion = serverConfigPayload.version + val accessToken = serverConfigPayload.token + val refreshToken = serverConfigPayload.refreshToken // Refresh only sent after login or refresh + + GlobalScope.launch(Dispatchers.IO) { + if (serverConnectionConfig == null) { // New Server Connection + val serverAddress = call.getString("address", "").toString() + + // Create new server connection config + val sscId = DeviceManager.getBase64Id("$serverAddress@$username") + val sscIndex = DeviceManager.deviceData.serverConnectionConfigs.size + + // Store refresh token securely if provided + val hasRefreshToken = if (!refreshToken.isNullOrEmpty()) { + secureStorage.storeRefreshToken(sscId, refreshToken) + } else { + false + } + Log.d(tag, "Refresh token secured = $hasRefreshToken") + + serverConnectionConfig = ServerConnectionConfig(sscId, sscIndex, "$serverAddress ($username)", serverAddress, serverVersion, userId, username, accessToken, serverConfigPayload.customHeaders) + + // Add and save + DeviceManager.deviceData.serverConnectionConfigs.add(serverConnectionConfig!!) + DeviceManager.deviceData.lastServerConnectionConfigId = serverConnectionConfig?.id + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + } else { + var shouldSave = false + if (serverConnectionConfig?.username != username || serverConnectionConfig?.token != accessToken || serverConnectionConfig?.version != serverVersion) { + serverConnectionConfig?.userId = userId + serverConnectionConfig?.username = username + serverConnectionConfig?.name = "${serverConnectionConfig?.address} (${serverConnectionConfig?.username})" + serverConnectionConfig?.version = serverVersion + serverConnectionConfig?.token = accessToken + shouldSave = true + } + + // Update refresh token if provided + if (!refreshToken.isNullOrEmpty()) { + val stored = secureStorage.storeRefreshToken(serverConnectionConfig!!.id, refreshToken) + Log.d(tag, "Refresh token secured = $stored") + } + + // Set last connection config + if (DeviceManager.deviceData.lastServerConnectionConfigId != serverConfigPayload.id) { + DeviceManager.deviceData.lastServerConnectionConfigId = serverConfigPayload.id + shouldSave = true + } + + if (shouldSave) DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + } + + DeviceManager.serverConnectionConfig = serverConnectionConfig + call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.serverConnectionConfig))) + } + } + + @PluginMethod + fun removeServerConnectionConfig(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + + // Remove refresh token if it exists + secureStorage.removeRefreshToken(serverConnectionConfigId) + + DeviceManager.deviceData.serverConnectionConfigs = DeviceManager.deviceData.serverConnectionConfigs.filter { it.id != serverConnectionConfigId } as MutableList + if (DeviceManager.deviceData.lastServerConnectionConfigId == serverConnectionConfigId) { + DeviceManager.deviceData.lastServerConnectionConfigId = null + } + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + if (DeviceManager.serverConnectionConfig?.id == serverConnectionConfigId) { + DeviceManager.serverConnectionConfig = null + } + call.resolve() + } + } + + @PluginMethod + fun getRefreshToken(call:PluginCall) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + + GlobalScope.launch(Dispatchers.IO) { + val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) + if (refreshToken != null) { + val result = JSObject() + result.put("refreshToken", refreshToken) + call.resolve(result) + } else { + call.resolve() + } + } + } + + @PluginMethod + fun clearRefreshToken(call:PluginCall) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + + val refreshToken = secureStorage.removeRefreshToken(serverConnectionConfigId) + val result = JSObject() + result.put("success", refreshToken) + call.resolve(result) + } + + @PluginMethod + fun getAccessToken(call:PluginCall) { + val serverConnectionConfigId = call.getString("serverConnectionConfigId", "").toString() + val serverConnectionConfig = DeviceManager.deviceData.serverConnectionConfigs.find { it.id == serverConnectionConfigId } + val token = serverConnectionConfig?.token ?: "" + val ret = JSObject() + ret.put("token", token) + call.resolve(ret) + } + + @PluginMethod + fun logout(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + DeviceManager.serverConnectionConfig = null + DeviceManager.deviceData.lastServerConnectionConfigId = null + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + call.resolve() + } + } + + @PluginMethod + fun getAllLocalMediaProgress(call:PluginCall) { + GlobalScope.launch(Dispatchers.IO) { + val localMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress() + call.resolve(JSObject(jacksonMapper.writeValueAsString(LocalMediaProgressPayload(localMediaProgress)))) + } + } + + @PluginMethod + fun getLocalMediaProgressForServerItem(call:PluginCall) { + val libraryItemId = call.getString("libraryItemId", "").toString() + var episodeId:String? = call.getString("episodeId", "").toString() + if (episodeId == "") episodeId = null + + GlobalScope.launch(Dispatchers.IO) { + val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress() + val localMediaProgress = allLocalMediaProgress.find { libraryItemId == it.libraryItemId && (episodeId == null || it.episodeId == episodeId) } + + if (localMediaProgress == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) + } + } + } + + @PluginMethod + fun removeLocalMediaProgress(call:PluginCall) { + val localMediaProgressId = call.getString("localMediaProgressId", "").toString() + DeviceManager.dbManager.removeLocalMediaProgress(localMediaProgressId) + call.resolve() + } + + @PluginMethod + fun syncLocalSessionsWithServer(call:PluginCall) { + if (DeviceManager.serverConnectionConfig == null) { + AbsLogger.error("AbsDatabase", "syncLocalSessionsWithServer: not connected to server") + return call.resolve() + } + + apiHandler.syncLocalMediaProgressForUser { + val savedSessions = DeviceManager.dbManager.getPlaybackSessions().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId } + + if (savedSessions.isNotEmpty()) { + apiHandler.sendSyncLocalSessions(savedSessions) { success, errorMsg -> + if (!success) { + call.resolve(JSObject("{\"error\":\"$errorMsg\"}")) + } else { + AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: Finished sending local playback sessions to server. Removing ${savedSessions.size} saved sessions.") + // Remove all local sessions + savedSessions.forEach { + DeviceManager.dbManager.removePlaybackSession(it.id) + } + call.resolve() + } + } + } else { + AbsLogger.info("AbsDatabase", "syncLocalSessionsWithServer: No saved local playback sessions to send to server.") + call.resolve() + } + } + } + + // Updates received via web socket + // This function doesn't need to sync with the server also because this data is coming from the server + // If sending the localMediaProgressId then update existing media progress + // If sending localLibraryItemId then save new local media progress + @PluginMethod + fun syncServerMediaProgressWithLocalMediaProgress(call:PluginCall) { + val serverMediaProgress = call.getObject("mediaProgress").toString() + val localLibraryItemId = call.getString("localLibraryItemId", "").toString() + var localEpisodeId:String? = call.getString("localEpisodeId", "").toString() + if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null + var localMediaProgressId = call.getString("localMediaProgressId") ?: "" + + val mediaProgress = jacksonMapper.readValue(serverMediaProgress) + + if (localMediaProgressId == "") { + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + if (localLibraryItem != null) { + localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" + + val localMediaProgress = LocalMediaProgress( + id = localMediaProgressId, + localLibraryItemId = localLibraryItemId, + localEpisodeId = localEpisodeId, + duration = mediaProgress.duration, + progress = mediaProgress.progress, + currentTime = mediaProgress.currentTime, + isFinished = mediaProgress.isFinished, + ebookLocation = mediaProgress.ebookLocation, + ebookProgress = mediaProgress.ebookProgress, + lastUpdate = mediaProgress.lastUpdate, + startedAt = mediaProgress.startedAt, + finishedAt = mediaProgress.finishedAt, + serverConnectionConfigId = localLibraryItem.serverConnectionConfigId, + serverAddress = localLibraryItem.serverAddress, + serverUserId = localLibraryItem.serverUserId, + libraryItemId = localLibraryItem.libraryItemId, + episodeId = mediaProgress.episodeId) + + Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress: Saving new local media progress $localMediaProgress") + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) + } else { + Log.e(tag, "syncServerMediaProgressWithLocalMediaProgress: Local library item not found") + } + } else { + Log.d(tag, "syncServerMediaProgressWithLocalMediaProgress $localMediaProgressId") + val localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId) + + if (localMediaProgress == null) { + Log.w(tag, "syncServerMediaProgressWithLocalMediaProgress Local media progress not found $localMediaProgressId") + call.resolve() + } else { + MediaEventManager.syncEvent(mediaProgress, "Received from webhook event") + + localMediaProgress.updateFromServerMediaProgress(mediaProgress) + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localMediaProgress))) + } + } + } + + @PluginMethod + fun updateLocalMediaProgressFinished(call:PluginCall) { + val localLibraryItemId = call.getString("localLibraryItemId", "").toString() + var localEpisodeId:String? = call.getString("localEpisodeId", "").toString() + if (localEpisodeId.isNullOrEmpty()) localEpisodeId = null + + val localMediaProgressId = if (localEpisodeId.isNullOrEmpty()) localLibraryItemId else "$localLibraryItemId-$localEpisodeId" + val isFinished = call.getBoolean("isFinished", false) == true + + Log.d(tag, "updateLocalMediaProgressFinished $localMediaProgressId | Is Finished:$isFinished") + var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId) + + if (localMediaProgress == null) { // Create new local media progress if does not exist + Log.d(tag, "updateLocalMediaProgressFinished Local Media Progress not found $localMediaProgressId - Creating new") + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + + if (localLibraryItem == null) { + return call.resolve(JSObject("{\"error\":\"Library Item not found\"}")) + } + if (localLibraryItem.mediaType != "podcast" && !localEpisodeId.isNullOrEmpty()) { + return call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) + } + + val duration: Double + var podcastEpisode: PodcastEpisode? = null + if (!localEpisodeId.isNullOrEmpty()) { + val podcast = localLibraryItem.media as Podcast + podcastEpisode = podcast.episodes?.find { episode -> + episode.id == localEpisodeId + } + if (podcastEpisode == null) { + return call.resolve(JSObject("{\"error\":\"Podcast episode not found\"}")) + } + duration = podcastEpisode.duration ?: 0.0 + } else { + val book = localLibraryItem.media as Book + duration = book.duration ?: 0.0 + } + + val currentTime = System.currentTimeMillis() + localMediaProgress = LocalMediaProgress( + id = localMediaProgressId, + localLibraryItemId = localLibraryItemId, + localEpisodeId = localEpisodeId, + duration = duration, + progress = if (isFinished) 1.0 else 0.0, + currentTime = 0.0, + isFinished = isFinished, + ebookLocation = null, + ebookProgress = null, + lastUpdate = currentTime, + startedAt = if (isFinished) currentTime else 0L, + finishedAt = if (isFinished) currentTime else null, + serverConnectionConfigId = localLibraryItem.serverConnectionConfigId, + serverAddress = localLibraryItem.serverAddress, + serverUserId = localLibraryItem.serverUserId, + libraryItemId = localLibraryItem.libraryItemId, + episodeId = podcastEpisode?.serverEpisodeId) + } else { + localMediaProgress.updateIsFinished(isFinished) + } + + // Save local media progress locally + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + + val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress) + Log.d(tag, "updateLocalMediaProgressFinished: Local Media Progress String $lmpstring") + + // Send update to server media progress is linked to a server and user is logged into that server + localMediaProgress.serverConnectionConfigId?.let { configId -> + if (DeviceManager.serverConnectionConfigId == configId) { + val libraryItemId = localMediaProgress.libraryItemId ?: "" + val episodeId = localMediaProgress.episodeId ?: "" + val updatePayload = JSObject() + updatePayload.put("isFinished", isFinished) + apiHandler.updateMediaProgress(libraryItemId,episodeId,updatePayload) { + Log.d(tag, "updateLocalMediaProgressFinished: Updated media progress isFinished on server") + val jsobj = JSObject() + jsobj.put("local", true) + jsobj.put("server", true) + jsobj.put("localMediaProgress", JSObject(lmpstring)) + call.resolve(jsobj) + } + } + } + if (localMediaProgress.serverConnectionConfigId == null || DeviceManager.serverConnectionConfigId != localMediaProgress.serverConnectionConfigId) { + val jsobj = JSObject() + jsobj.put("local", true) + jsobj.put("server", false) + jsobj.put("localMediaProgress", JSObject(lmpstring)) + call.resolve(jsobj) + } + } + + @PluginMethod + fun updateLocalEbookProgress(call:PluginCall) { + val localLibraryItemId = call.getString("localLibraryItemId", "").toString() + val ebookLocation = call.getString("ebookLocation", "").toString() + val ebookProgress = call.getDouble("ebookProgress") ?: 0.0 + + val localMediaProgressId = localLibraryItemId + var localMediaProgress = DeviceManager.dbManager.getLocalMediaProgress(localMediaProgressId) + + if (localMediaProgress == null) { + Log.d(tag, "updateLocalEbookProgress Local Media Progress not found $localMediaProgressId - Creating new") + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + ?: return call.resolve(JSObject("{\"error\":\"Library Item not found\"}")) + + val book = localLibraryItem.media as Book + + localMediaProgress = LocalMediaProgress( + id = localMediaProgressId, + localLibraryItemId = localLibraryItemId, + localEpisodeId = null, + duration = book.duration ?: 0.0, + progress = 0.0, + currentTime = 0.0, + isFinished = false, + ebookLocation = ebookLocation, + ebookProgress = ebookProgress, + lastUpdate = System.currentTimeMillis(), + startedAt = 0L, + finishedAt = null, + serverConnectionConfigId = localLibraryItem.serverConnectionConfigId, + serverAddress = localLibraryItem.serverAddress, + serverUserId = localLibraryItem.serverUserId, + libraryItemId = localLibraryItem.libraryItemId, + episodeId = null) + } else { + localMediaProgress.updateEbookProgress(ebookLocation, ebookProgress) + } + + // Save local media progress locally + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + + val lmpstring = jacksonMapper.writeValueAsString(localMediaProgress) + Log.d(tag, "updateLocalEbookProgress: Local Media Progress String $lmpstring") + + val jsobj = JSObject() + jsobj.put("localMediaProgress", JSObject(lmpstring)) + call.resolve(jsobj) + } + + @PluginMethod + fun updateLocalTrackOrder(call:PluginCall) { + val localLibraryItemId = call.getString("localLibraryItemId", "") ?: "" + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + if (localLibraryItem == null) { + call.resolve() + return + } + + val audioTracks = localLibraryItem.media.getAudioTracks() as MutableList + + val tracks:JSArray = call.getArray("tracks") ?: JSArray() + Log.d(tag, "updateLocalTrackOrder $tracks") + + var index = 1 + var hasUpdates = false + for (i in 0 until tracks.length()) { + val track = tracks.getJSONObject(i) + val localFileId = track.getString("localFileId") + + val existingTrack = audioTracks.find{ it.localFileId == localFileId } + if (existingTrack != null) { + Log.d(tag, "Found existing track ${existingTrack.localFileId} that has index ${existingTrack.index} should be index $index") + if (existingTrack.index != index) hasUpdates = true + existingTrack.index = index++ + } else { + Log.e(tag, "Audio track with local file id not found") + } + } + + if (hasUpdates) { + Log.d(tag, "Save library item track orders") + localLibraryItem.media.setAudioTracks(audioTracks) + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) + } else { + Log.d(tag, "No tracks need to be updated") + call.resolve() + } + } + + @PluginMethod + fun updateDeviceSettings(call:PluginCall) { // Returns device data + Log.d(tag, "updateDeviceSettings called") + val newDeviceSettings = jacksonMapper.readValue(call.data.toString()) + + Handler(Looper.getMainLooper()).post { + DeviceManager.deviceData.deviceSettings = newDeviceSettings + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + + // Updates playback actions for media notification (handles media control seek locking setting) + if (mainActivity.isPlayerNotificationServiceInitialized()) { + mainActivity.foregroundService.setMediaSessionConnectorPlaybackActions() + } + + call.resolve(JSObject(jacksonMapper.writeValueAsString(DeviceManager.deviceData))) + } + } + + @PluginMethod + fun getMediaItemHistory(call:PluginCall) { // Returns device data + Log.d(tag, "getMediaItemHistory called") + val mediaId = call.getString("mediaId") ?: "" + + GlobalScope.launch(Dispatchers.IO) { + val mediaItemHistory = DeviceManager.dbManager.getMediaItemHistory(mediaId) + if (mediaItemHistory == null) { + call.resolve() + } else { + call.resolve(JSObject(jacksonMapper.writeValueAsString(mediaItemHistory))) + } + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt new file mode 100644 index 0000000..cd678a1 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsDownloader.kt @@ -0,0 +1,272 @@ +package com.audiobookshelf.app.plugins + +import android.os.Environment +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.models.DownloadItem +import com.audiobookshelf.app.models.DownloadItemPart +import com.audiobookshelf.app.server.ApiHandler +import com.audiobookshelf.app.managers.DownloadItemManager +import com.audiobookshelf.app.services.DownloadServiceHost +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import java.io.File + +@CapacitorPlugin(name = "AbsDownloader") +class AbsDownloader : Plugin() { + private val tag = "AbsDownloader" + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + lateinit var mainActivity: MainActivity + lateinit var apiHandler: ApiHandler + lateinit var downloadItemManager: DownloadItemManager + + private val clientEventEmitter = (object : DownloadItemManager.DownloadEventEmitter { + override fun onDownloadItem(downloadItem:DownloadItem) { + notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(downloadItem))) + } + override fun onDownloadItemPartUpdate(downloadItemPart:DownloadItemPart) { + notifyListeners("onDownloadItemPartUpdate", JSObject(jacksonMapper.writeValueAsString(downloadItemPart))) + } + override fun onDownloadItemComplete(jsobj:JSObject) { + notifyListeners("onItemDownloadComplete", jsobj) + } + override fun onQueueChanged(hasWork: Boolean) { + notifyListeners("onQueueChanged", JSObject().put("hasWork", hasWork)) + } + }) + + override fun load() { + mainActivity = (activity as MainActivity) + apiHandler = ApiHandler(mainActivity) + downloadItemManager = DownloadServiceHost.ensure(mainActivity) + DownloadServiceHost.attachBridge(mainActivity, clientEventEmitter) + } + + override fun handleOnDestroy() { + DownloadServiceHost.detachBridge() + super.handleOnDestroy() + } + + @PluginMethod + fun setDownloadNotificationStrings(call: PluginCall) { + DownloadServiceHost.setNotificationStrings( + mainActivity, + call.getString("preparing") ?: "Preparing downloads", + call.getString("downloadingFile") ?: "Downloading {0}", + call.getString("waitingForStorage") ?: "Waiting for available storage", + call.getString("downloads") ?: "Downloads", + call.getString("cancel") ?: "Cancel") + call.resolve() + } + + /** Replays restored queue items when the frontend subscribes to download events. */ + @PluginMethod(returnType = PluginMethod.RETURN_NONE) + override fun addListener(call: PluginCall) { + super.addListener(call) + if (call.getString("eventName") == "onDownloadItem" && ::downloadItemManager.isInitialized) { + downloadItemManager.downloadItemQueue.forEach { item -> + notifyListeners("onDownloadItem", JSObject(jacksonMapper.writeValueAsString(item))) + } + } + } + + @PluginMethod + fun downloadLibraryItem(call: PluginCall) { + val libraryItemId = call.data.getString("libraryItemId").toString() + var episodeId = call.data.getString("episodeId").toString() + if (episodeId == "null") episodeId = "" + var localFolderId = call.data.getString("localFolderId", "").toString() + AbsLogger.info(tag, "Requested download for item $libraryItemId${if (episodeId.isEmpty()) "" else " / episode $episodeId"}") + + val downloadId = if (episodeId.isEmpty()) libraryItemId else "$libraryItemId-$episodeId" + DownloadServiceHost.retryExisting(mainActivity, downloadId) { result -> + when (result) { + DownloadServiceHost.ExistingDownloadResult.RETRIED -> call.resolve() + DownloadServiceHost.ExistingDownloadResult.ACTIVE -> { + AbsLogger.info(tag, "Download already active for $downloadId") + call.resolve(JSObject("{\"error\":\"Download already started for this media entity\"}")) + } + DownloadServiceHost.ExistingDownloadResult.SERVICE_START_FAILED -> + call.resolve(JSObject("{\"error\":\"Unable to start the Android download service\"}")) + DownloadServiceHost.ExistingDownloadResult.NOT_FOUND -> { + apiHandler.getLibraryItemWithProgress(libraryItemId, episodeId) { libraryItem -> + if (libraryItem == null) { + call.resolve(JSObject("{\"error\":\"Server request failed\"}")) + } else { + AbsLogger.info(tag, "Preparing download for ${libraryItem.id}") + + if (localFolderId == "") localFolderId = "internal-${libraryItem.mediaType}" + var localFolder = DeviceManager.dbManager.getLocalFolder(localFolderId) + if (localFolder == null && localFolderId.startsWith("internal-")) { + AbsLogger.info(tag, "Creating internal download folder $localFolderId") + localFolder = LocalFolder(localFolderId, "Internal App Storage", "", "", "", "internal", libraryItem.mediaType) + DeviceManager.dbManager.saveLocalFolder(localFolder) + } + + if (localFolder == null) { + call.resolve(JSObject("{\"error\":\"Local Folder Not Found\"}")) + } else if (episodeId.isNotEmpty() && libraryItem.mediaType != "podcast") { + call.resolve(JSObject("{\"error\":\"Invalid library item not a podcast\"}")) + } else if (episodeId.isNotEmpty()) { + val podcast = libraryItem.media as Podcast + val episode = podcast.episodes?.find { it.id == episodeId } + if (episode == null) { + call.resolve(JSObject("{\"error\":\"Invalid podcast episode not found\"}")) + } else { + startLibraryItemDownload(libraryItem, localFolder, episode) { error -> resolveDownloadCall(call, error) } + } + } else { + startLibraryItemDownload(libraryItem, localFolder, null) { error -> resolveDownloadCall(call, error) } + } + } + } + } + } + } + } + + private fun resolveDownloadCall(call: PluginCall, error: String?) { + if (error == null) call.resolve() + else call.resolve(JSObject().put("error", error)) + } + + // Item filenames could be the same if they are in sub-folders, this will make them unique + private fun getFilenameFromRelPath(relPath: String): String { + var cleanedRelPath = relPath.replace("\\", "_").replace("/", "_") + cleanedRelPath = cleanStringForFileSystem(cleanedRelPath) + return if (cleanedRelPath.startsWith("_")) cleanedRelPath.substring(1) else cleanedRelPath + } + + // Replace characters that cant be used in the file system + // Reserved characters: ?:\"*|/\\<> + private fun cleanStringForFileSystem(str:String):String { + val reservedCharacters = listOf("?", "\"", "*", "|", "/", "\\", "<", ">") + var newTitle = str + newTitle = newTitle.replace(":", " -") // Special case replace : with - + + reservedCharacters.forEach { + newTitle = newTitle.replace(it, "") + } + return newTitle + } + + private fun startLibraryItemDownload( + libraryItem: LibraryItem, + localFolder: LocalFolder, + episode: PodcastEpisode?, + callback: (String?) -> Unit + ) { + val isInternal = localFolder.id.startsWith("internal-") + + val finalInternalFolderPath = "${mainActivity.filesDir}/downloads/${libraryItem.id}" + val tempFolderPath = + if (isInternal) { + "${mainActivity.filesDir}/download-staging/${libraryItem.id}" + } else { + "${mainActivity.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS) ?: mainActivity.filesDir}/download-staging/${libraryItem.id}" + } + + + if (libraryItem.mediaType == "book") { + val bookTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) + val bookAuthor = cleanStringForFileSystem(libraryItem.media.metadata.getAuthorDisplayName()) + + val tracks = libraryItem.media.getAudioTracks() + AbsLogger.info(tag, "Queueing library item download with ${tracks.size} files") + val itemSubfolder = "$bookAuthor/$bookTitle" + val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$itemSubfolder" + val downloadItem = DownloadItem(libraryItem.id, libraryItem.id, null, libraryItem.userMediaProgress,DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, bookTitle, itemSubfolder, libraryItem.media, mutableListOf()) + + val book = libraryItem.media as Book + book.ebookFile?.let { ebookFile -> + val fileSize = ebookFile.metadata?.size ?: 0 + val serverPath = "/api/items/${libraryItem.id}/file/${ebookFile.ino}/download" + val destinationFilename = getFilenameFromRelPath(ebookFile.metadata?.relPath ?: "") + val finalDestinationFile = File("$itemFolderPath/$destinationFilename") + val destinationFile = File("$tempFolderPath/$destinationFilename.part") + + val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,ebookFile,null,null) + downloadItem.downloadItemParts.add(downloadItemPart) + } + + // Create download item part for each audio track + val audioFiles = (libraryItem.media as Book).audioFiles ?: mutableListOf() + tracks.forEach { audioTrack -> + val fileSize = audioTrack.metadata?.size ?: 0 + + // TODO: Currently file ino is only stored on AudioFile. This should be updated server side to be in FileMetadata or on the AudioTrack + val audioFileIno = audioFiles.find { it.metadata.path == audioTrack.metadata?.path }?.ino + + val serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download" + val destinationFilename = getFilenameFromRelPath(audioTrack.relPath) + + val finalDestinationFile = File("$itemFolderPath/$destinationFilename") + val destinationFile = File("$tempFolderPath/$destinationFilename.part") + + val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, fileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,audioTrack,null) + downloadItem.downloadItemParts.add(downloadItemPart) + } + + if (downloadItem.downloadItemParts.isNotEmpty()) { + // Add cover download item + if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { + val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath } + val coverFileSize = coverLibraryFile?.metadata?.size ?: 0 + + val serverPath = "/api/items/${libraryItem.id}/cover" + val destinationFilename = "cover-${libraryItem.id}.jpg" + val destinationFile = File("$tempFolderPath/$destinationFilename.part") + val finalDestinationFile = File("$itemFolderPath/$destinationFilename") + + val downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename, coverFileSize, destinationFile,finalDestinationFile,itemSubfolder,serverPath,localFolder,null,null,null) + downloadItem.downloadItemParts.add(downloadItemPart) + } + + DownloadServiceHost.enqueue(mainActivity, downloadItem, callback) + } else callback("No downloadable files found") + } else { + // Podcast episode download + val podcastTitle = cleanStringForFileSystem(libraryItem.media.metadata.title) + + val audioTrack = episode?.audioTrack + val audioFileIno = episode?.audioFile?.ino + val fileSize = audioTrack?.metadata?.size ?: 0 + + AbsLogger.info(tag, "Queueing podcast episode download") + val itemFolderPath = if (isInternal) finalInternalFolderPath else "${localFolder.absolutePath}/$podcastTitle" + val downloadItemId = "${libraryItem.id}-${episode?.id}" + val downloadItem = DownloadItem(downloadItemId, libraryItem.id, episode?.id, libraryItem.userMediaProgress, DeviceManager.serverConnectionConfig?.id ?: "", DeviceManager.serverAddress, DeviceManager.serverUserId, libraryItem.mediaType, itemFolderPath, localFolder, podcastTitle, podcastTitle, libraryItem.media, mutableListOf()) + + var serverPath = "/api/items/${libraryItem.id}/file/${audioFileIno}/download" + var destinationFilename = getFilenameFromRelPath(audioTrack?.relPath ?: "") + + var destinationFile = File("$tempFolderPath/$destinationFilename.part") + var finalDestinationFile = File("$itemFolderPath/$destinationFilename") + var downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,fileSize, destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,audioTrack,episode) + downloadItem.downloadItemParts.add(downloadItemPart) + + if (libraryItem.media.coverPath != null && libraryItem.media.coverPath?.isNotEmpty() == true) { + val coverLibraryFile = libraryItem.libraryFiles?.find { it.metadata.path == libraryItem.media.coverPath } + val coverFileSize = coverLibraryFile?.metadata?.size ?: 0 + + serverPath = "/api/items/${libraryItem.id}/cover" + destinationFilename = "cover.jpg" + + destinationFile = File("$tempFolderPath/$destinationFilename.part") + finalDestinationFile = File("$itemFolderPath/$destinationFilename") + + downloadItemPart = DownloadItemPart.make(downloadItem.id, destinationFilename,coverFileSize,destinationFile,finalDestinationFile,podcastTitle,serverPath,localFolder,null,null,null) + downloadItem.downloadItemParts.add(downloadItemPart) + } + + DownloadServiceHost.enqueue(mainActivity, downloadItem, callback) + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt new file mode 100644 index 0000000..307c6c0 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsFileSystem.kt @@ -0,0 +1,328 @@ +package com.audiobookshelf.app.plugins + +import android.app.AlertDialog +import android.content.Context +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.annotation.RequiresApi +import androidx.documentfile.provider.DocumentFile +import com.anggrayudi.storage.SimpleStorage +import com.anggrayudi.storage.callback.FolderPickerCallback +import com.anggrayudi.storage.callback.StorageAccessCallback +import com.anggrayudi.storage.file.* +import com.audiobookshelf.app.MainActivity +import com.audiobookshelf.app.data.LocalFolder +import com.audiobookshelf.app.device.DeviceManager +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.* +import com.getcapacitor.annotation.CapacitorPlugin +import java.io.File + +@CapacitorPlugin(name = "AbsFileSystem") +class AbsFileSystem : Plugin() { + private val TAG = "AbsFileSystem" + private val tag = "AbsFileSystem" + private var jacksonMapper = + jacksonObjectMapper() + .enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + lateinit var mainActivity: MainActivity + + override fun load() { + mainActivity = (activity as MainActivity) + + mainActivity.storage.storageAccessCallback = + object : StorageAccessCallback { + override fun onRootPathNotSelected( + requestCode: Int, + rootPath: String, + uri: Uri, + selectedStorageType: StorageType, + expectedStorageType: StorageType + ) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onCanceledByUser(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onExpectedStorageNotSelected( + requestCode: Int, + selectedFolder: DocumentFile, + selectedStorageType: StorageType, + expectedBasePath: String, + expectedStorageType: StorageType + ) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + + override fun onRootPathPermissionGranted(requestCode: Int, root: DocumentFile) { + Log.d(TAG, "STORAGE ACCESS CALLBACK") + } + } + } + + @PluginMethod + fun setFolderPickerStrings(call: PluginCall) { + mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_WRITE_ACCESS_REQUIRED, call.getString("writeAccessRequired")) + .putString(KEY_ALLOW, call.getString("allow")) + .putString(KEY_CANCEL, call.getString("cancel")) + .putString(KEY_ACCESS_DENIED, call.getString("accessDenied")) + .putString(KEY_PERMISSION_DENIED, call.getString("permissionDenied")) + .apply() + call.resolve() + } + + @PluginMethod + fun selectFolder(call: PluginCall) { + val mediaType = call.data.getString("mediaType", "book").toString() + val REQUEST_CODE_SELECT_FOLDER = 6 + val REQUEST_CODE_SDCARD_ACCESS = 7 + + mainActivity.storage.folderPickerCallback = + object : FolderPickerCallback { + override fun onFolderSelected(requestCode: Int, folder: DocumentFile) { + Log.d(TAG, "ON FOLDER SELECTED ${folder.uri} ${folder.name}") + val absolutePath = folder.getAbsolutePath(activity) + val storageType = folder.getStorageType(activity) + val basePath = folder.getBasePath(activity) + val folderId = + android.util.Base64.encodeToString( + folder.id.toByteArray(), + android.util.Base64.DEFAULT + ) + + val localFolder = + LocalFolder( + folderId, + folder.name ?: "", + folder.uri.toString(), + basePath, + absolutePath, + storageType.toString(), + mediaType + ) + + DeviceManager.dbManager.saveLocalFolder(localFolder) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localFolder))) + } + + override fun onStorageAccessDenied( + requestCode: Int, + folder: DocumentFile?, + storageType: StorageType, + storageId: String + ) { + Log.e(tag, "Storage Access Denied ${folder?.getAbsolutePath(mainActivity)}") + + val jsobj = JSObject() + if (requestCode == REQUEST_CODE_SELECT_FOLDER) { + + val builder: AlertDialog.Builder = AlertDialog.Builder(mainActivity) + builder.setMessage(folderPickerString(KEY_WRITE_ACCESS_REQUIRED, DEFAULT_WRITE_ACCESS_REQUIRED)) + builder.setNegativeButton(folderPickerString(KEY_CANCEL, DEFAULT_CANCEL)) { _, _ -> + run { + jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED)) + call.resolve(jsobj) + } + } + builder.setPositiveButton(folderPickerString(KEY_ALLOW, DEFAULT_ALLOW)) { _, _ -> + mainActivity.storageHelper.requestStorageAccess( + REQUEST_CODE_SDCARD_ACCESS, + initialPath = FileFullPath(mainActivity, storageId, "") + ) + } + builder.show() + } else { + Log.d(TAG, "STORAGE ACCESS DENIED $requestCode") + jsobj.put("error", folderPickerString(KEY_ACCESS_DENIED, DEFAULT_ACCESS_DENIED)) + call.resolve(jsobj) + } + } + + override fun onStoragePermissionDenied(requestCode: Int) { + Log.d(TAG, "STORAGE PERMISSION DENIED $requestCode") + val jsobj = JSObject() + jsobj.put("error", folderPickerString(KEY_PERMISSION_DENIED, DEFAULT_PERMISSION_DENIED)) + call.resolve(jsobj) + } + } + + mainActivity.storage.openFolderPicker(REQUEST_CODE_SELECT_FOLDER) + } + + @RequiresApi(Build.VERSION_CODES.R) + @PluginMethod + fun requestStoragePermission(call: PluginCall) { + Log.d(TAG, "Request Storage Permissions") + mainActivity.storageHelper.requestStorageAccess() + call.resolve() + } + + @PluginMethod + fun checkStoragePermission(call: PluginCall) { + val res: Boolean + if (Build.VERSION.SDK_INT <= android.os.Build.VERSION_CODES.P) { + res = SimpleStorage.hasStoragePermission(context) + Log.d(TAG, "checkStoragePermission: Check Storage Access $res") + } else { + Log.d(TAG, "checkStoragePermission: Has permission on Android 10 or up") + res = true + } + + val jsobj = JSObject() + jsobj.put("value", res) + call.resolve(jsobj) + } + + @PluginMethod + fun checkFolderPermissions(call: PluginCall) { + val folderUrl = call.data.getString("folderUrl", "").toString() + Log.d(TAG, "Check Folder Permissions for $folderUrl") + + val hasAccess = SimpleStorage.hasStorageAccess(context, folderUrl, true) + + val jsobj = JSObject() + jsobj.put("value", hasAccess) + call.resolve(jsobj) + } + + @PluginMethod + fun getSDKVersion(call: PluginCall) { + val jsObject = JSObject() + jsObject.put("version", Build.VERSION.SDK_INT) + call.resolve(jsObject) + } + + @PluginMethod + fun removeFolder(call: PluginCall) { + val folderId = call.data.getString("folderId", "").toString() + DeviceManager.dbManager.removeLocalFolder(folderId) + call.resolve() + } + + @PluginMethod + fun removeLocalLibraryItem(call: PluginCall) { + val localLibraryItemId = call.data.getString("localLibraryItemId", "").toString() + DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) + call.resolve() + } + + @PluginMethod + fun deleteItem(call: PluginCall) { + val localLibraryItemId = call.data.getString("id", "").toString() + val absolutePath = call.data.getString("absolutePath", "").toString() + val contentUrl = call.data.getString("contentUrl", "").toString() + Log.d(tag, "deleteItem $absolutePath | $contentUrl") + + // Check if should delete subfolder + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + + val success: Boolean + + // If internal library item use File to delete + if (localLibraryItem?.folderId?.startsWith("internal-") == true) { + Log.d(tag, "Deleting internal library item at absolutePath $absolutePath") + val file = File(absolutePath) + success = + if (file.exists()) { + file.deleteRecursively() + } else { + true + } + } else { + var subfolderPathToDelete = "" + localLibraryItem?.folderId?.let { folderId -> + val folder = DeviceManager.dbManager.getLocalFolder(folderId) + folder?.absolutePath?.let { folderPath -> + val splitAbsolutePath = absolutePath.split("/") + val fullSubDir = + splitAbsolutePath.subList(0, splitAbsolutePath.size - 1).joinToString("/") + if (fullSubDir != folderPath) { + val subdirHasAnItem = + DeviceManager.dbManager.getLocalLibraryItems().any { _localLibraryItem -> + if (_localLibraryItem.id == localLibraryItemId) { + false + } else { + _localLibraryItem.absolutePath.startsWith(fullSubDir) + } + } + subfolderPathToDelete = if (subdirHasAnItem) "" else fullSubDir + } + } + } + + val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) + if (docfile?.exists() == true) { + success = docfile.delete() == true + } else { + Log.d(tag, "Folder $contentUrl doesn't exist") + success = true + } + + if (subfolderPathToDelete != "") { + Log.d(tag, "Deleting empty subfolder at $subfolderPathToDelete") + val docfilesub = DocumentFileCompat.fromFullPath(mainActivity, subfolderPathToDelete) + docfilesub?.delete() + } + } + + if (success) { + DeviceManager.dbManager.removeLocalLibraryItem(localLibraryItemId) + } + call.resolve(JSObject("{\"success\":$success}")) + } + + @PluginMethod + fun deleteTrackFromItem(call: PluginCall) { + val localLibraryItemId = call.data.getString("id", "").toString() + val trackLocalFileId = call.data.getString("trackLocalFileId", "").toString() + val contentUrl = call.data.getString("trackContentUrl", "").toString() + Log.d(tag, "deleteTrackFromItem $contentUrl") + + val localLibraryItem = DeviceManager.dbManager.getLocalLibraryItem(localLibraryItemId) + if (localLibraryItem == null) { + Log.e(tag, "deleteTrackFromItem: LLI does not exist $localLibraryItemId") + return call.resolve(JSObject("{\"success\":false}")) + } + + val docfile = DocumentFileCompat.fromUri(mainActivity, Uri.parse(contentUrl)) + val success = docfile?.delete() == true + if (success) { + localLibraryItem.media.removeAudioTrack(trackLocalFileId) + localLibraryItem.removeLocalFile(trackLocalFileId) + DeviceManager.dbManager.saveLocalLibraryItem(localLibraryItem) + call.resolve(JSObject(jacksonMapper.writeValueAsString(localLibraryItem))) + } else { + call.resolve(JSObject("{\"success\":false}")) + } + } + + private fun folderPickerString(key: String, defaultValue: String): String = + mainActivity.getSharedPreferences(FOLDER_PICKER_PREFERENCES, Context.MODE_PRIVATE) + .getString(key, defaultValue) ?: defaultValue + + private companion object { + const val FOLDER_PICKER_PREFERENCES = "folder_picker" + const val KEY_WRITE_ACCESS_REQUIRED = "write_access_required" + const val KEY_ALLOW = "allow" + const val KEY_CANCEL = "cancel" + const val KEY_ACCESS_DENIED = "access_denied" + const val KEY_PERMISSION_DENIED = "permission_denied" + const val DEFAULT_WRITE_ACCESS_REQUIRED = + "You do not have write access to this folder. Would you like to grant access?" + const val DEFAULT_ALLOW = "Allow" + const val DEFAULT_CANCEL = "Cancel" + const val DEFAULT_ACCESS_DENIED = "Access denied" + const val DEFAULT_PERMISSION_DENIED = "Permission denied" + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsLogger.kt b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsLogger.kt new file mode 100644 index 0000000..ababfb5 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/plugins/AbsLogger.kt @@ -0,0 +1,80 @@ +package com.audiobookshelf.app.plugins + +import android.util.Log +import com.audiobookshelf.app.device.DeviceManager +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.getcapacitor.JSObject +import com.getcapacitor.Plugin +import com.getcapacitor.PluginCall +import com.getcapacitor.PluginMethod +import com.getcapacitor.annotation.CapacitorPlugin +import java.util.UUID + +data class AbsLog( + var id:String, + var tag:String, + var level:String, + var message:String, + var timestamp:Long +) + +data class AbsLogList(val value:List) + +@CapacitorPlugin(name = "AbsLogger") +class AbsLogger : Plugin() { + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + + override fun load() { + onLogEmitter = { log:AbsLog -> + notifyListeners("onLog", JSObject(jacksonMapper.writeValueAsString(log))) + } + info("AbsLogger", "load: AbsLogger plugin initialized") + } + + companion object { + var onLogEmitter:((log:AbsLog) -> Unit)? = null + + fun log(level:String, tag:String, message:String) { + val absLog = AbsLog(id = UUID.randomUUID().toString(), tag, level, message, timestamp = System.currentTimeMillis()) + DeviceManager.dbManager.saveLog(absLog) + onLogEmitter?.let { it(absLog) } + } + fun info(tag:String, message:String) { + Log.i("AbsLogger", message) + log("info", tag, message) + } + fun error(tag:String, message:String) { + Log.e("AbsLogger", message) + log("error", tag, message) + } + } + + @PluginMethod + fun info(call: PluginCall) { + val msg = call.getString("message") ?: return call.reject("No message") + val tag = call.getString("tag") ?: "" + info(tag, msg) + call.resolve() + } + + @PluginMethod + fun error(call: PluginCall) { + val msg = call.getString("message") ?: return call.reject("No message") + val tag = call.getString("tag") ?: "" + error(tag, msg) + call.resolve() + } + + @PluginMethod + fun getAllLogs(call: PluginCall) { + val absLogs = DeviceManager.dbManager.getAllLogs() + call.resolve(JSObject(jacksonMapper.writeValueAsString(AbsLogList(absLogs)))) + } + + @PluginMethod + fun clearLogs(call: PluginCall) { + DeviceManager.dbManager.removeAllLogs() + call.resolve() + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt new file mode 100644 index 0000000..2ad61a7 --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/server/ApiHandler.kt @@ -0,0 +1,840 @@ +package com.audiobookshelf.app.server + +import android.annotation.SuppressLint +import android.content.Context +import android.os.Build +import android.provider.Settings +import android.util.Base64 +import android.util.Log +import com.audiobookshelf.app.data.* +import com.audiobookshelf.app.device.DeviceManager +import com.audiobookshelf.app.media.MediaEventManager +import com.audiobookshelf.app.media.MediaProgressSyncData +import com.audiobookshelf.app.media.SyncResult +import com.audiobookshelf.app.models.User +import com.audiobookshelf.app.BuildConfig +import com.audiobookshelf.app.plugins.AbsLogger +import com.audiobookshelf.app.managers.SecureStorage +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.core.json.JsonReadFeature +import com.fasterxml.jackson.databind.node.ObjectNode +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.getcapacitor.JSArray +import com.getcapacitor.JSObject +import okhttp3.* +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.internal.EMPTY_REQUEST +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.util.concurrent.TimeUnit + +class ApiHandler(var ctx:Context) { + val tag = "ApiHandler" + + companion object { + // For sending data back to the Webview frontend + lateinit var absDatabaseNotifyListeners:(String, JSObject) -> Unit + + fun checkAbsDatabaseNotifyListenersInitted():Boolean { + return ::absDatabaseNotifyListeners.isInitialized + } + } + + private var defaultClient = OkHttpClient() + private var pingClient = OkHttpClient.Builder().callTimeout(3, TimeUnit.SECONDS).build() + private var jacksonMapper = jacksonObjectMapper().enable(JsonReadFeature.ALLOW_UNESCAPED_CONTROL_CHARS.mappedFeature()) + private var secureStorage = SecureStorage(ctx) + + @JsonIgnoreProperties(ignoreUnknown = true) + data class LocalSessionSyncResult(val id:String, val success:Boolean, val progressSynced:Boolean?, val error:String?) + data class LocalSessionsSyncResponsePayload(val results:List) + + private fun getRequest(endpoint:String, httpClient:OkHttpClient?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) { + val address = config?.address ?: DeviceManager.serverAddress + val token = config?.token ?: DeviceManager.token + + try { + val request = Request.Builder() + .url("${address}$endpoint").addHeader("Authorization", "Bearer $token") + .build() + makeRequest(request, httpClient, cb) + } catch(e: Exception) { + e.printStackTrace() + val jsobj = JSObject() + jsobj.put("error", "Request failed: ${e.message}") + cb(jsobj) + } + } + + private fun postRequest(endpoint:String, payload: JSObject?, config:ServerConnectionConfig?, cb: (JSObject) -> Unit) { + val address = config?.address ?: DeviceManager.serverAddress + val token = config?.token ?: DeviceManager.token + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = payload?.toString()?.toRequestBody(mediaType) ?: EMPTY_REQUEST + val requestUrl = "${address}$endpoint" + Log.d(tag, "postRequest to $requestUrl") + try { + val request = Request.Builder().post(requestBody) + .url(requestUrl).addHeader("Authorization", "Bearer ${token}") + .build() + makeRequest(request, null, cb) + } catch(e: Exception) { + e.printStackTrace() + val jsobj = JSObject() + jsobj.put("error", "Request failed: ${e.message}") + cb(jsobj) + } + } + + private fun patchRequest(endpoint:String, payload: JSObject, cb: (JSObject) -> Unit) { + val mediaType = "application/json; charset=utf-8".toMediaType() + val requestBody = payload.toString().toRequestBody(mediaType) + try { + val request = Request.Builder().patch(requestBody) + .url("${DeviceManager.serverAddress}$endpoint").addHeader("Authorization", "Bearer ${DeviceManager.token}") + .build() + makeRequest(request, null, cb) + } catch(e: Exception) { + e.printStackTrace() + val jsobj = JSObject() + jsobj.put("error", "Request failed: ${e.message}") + cb(jsobj) + } + } + + private fun makeRequest(request:Request, httpClient:OkHttpClient?, cb: (JSObject) -> Unit) { + val client = httpClient ?: defaultClient + + client.newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.d(tag, "FAILURE TO CONNECT") + e.printStackTrace() + + val jsobj = JSObject() + jsobj.put("error", "Failed to connect") + cb(jsobj) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (it.code == 401) { + // Handle 401 Unauthorized by attempting token refresh + AbsLogger.info(tag, "makeRequest: 401 Unauthorized for request to \"${request.url}\" - attempt token refresh") + handleTokenRefresh(request, httpClient, cb) + return + } + + if (!it.isSuccessful) { + val jsobj = JSObject() + jsobj.put("error", "Unexpected code $response") + cb(jsobj) + return + } + + val bodyString = it.body!!.string() + if (bodyString == "OK") { + cb(JSObject()) + } else { + try { + var jsonObj = JSObject() + if (bodyString.startsWith("[")) { + val array = JSArray(bodyString) + jsonObj.put("value", array) + } else { + jsonObj = JSObject(bodyString) + } + cb(jsonObj) + } catch(je:JSONException) { + Log.e(tag, "Invalid JSON response ${je.localizedMessage} from body $bodyString") + val jsobj = JSObject() + jsobj.put("error", "Invalid response body") + cb(jsobj) + } + } + } + } + }) + } + + /** + * Handles token refresh when a 401 Unauthorized response is received + * This function will: + * 1. Get the refresh token from secure storage for the current server connection + * 2. Make a request to /auth/refresh endpoint with the refresh token + * 3. Update the stored tokens with the new access token + * 4. Retry the original request with the new access token + * 5. If refresh fails, fail the request ([refreshAuthTokens] owns clearing the session) + * + * @param originalRequest The original request that failed with 401 + * @param httpClient The HTTP client to use for the request + * @param callback The callback to return the response + */ + private fun handleTokenRefresh(originalRequest: Request, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) { + val serverConnectionConfigId = DeviceManager.serverConnectionConfigId + refreshAuthTokens(serverConnectionConfigId, httpClient) { result -> + if (result is RefreshResult.Success) { + retryOriginalRequest(originalRequest, result.accessToken, httpClient, callback) + } else { + callback(JSObject().put("error", "Authentication failed - login again")) + } + } + } + + sealed interface RefreshResult { + data class Success(val accessToken: String) : RefreshResult + + /** The server rejected the refresh token, so the session has already been cleared. */ + data object Rejected : RefreshResult + + /** The refresh could not be completed. Credentials are untouched and the caller may retry. */ + data object Transient : RefreshResult + } + + /** Refreshes tokens for a specific saved server, including downloads queued while another server is active. */ + fun refreshAuthTokens( + serverConnectionConfigId: String, + httpClient: OkHttpClient? = null, + onResult: (RefreshResult) -> Unit + ) { + val config = DeviceManager.getServerConnectionConfig(serverConnectionConfigId) + val refreshToken = secureStorage.getRefreshToken(serverConnectionConfigId) + if (config == null || refreshToken.isNullOrEmpty()) { + AbsLogger.error(tag, "No refresh token or server configuration for $serverConnectionConfigId") + handleRefreshRejected(serverConnectionConfigId) + onResult(RefreshResult.Rejected) + return + } + val request = try { + Request.Builder() + .url("${config.address}/auth/refresh") + .addHeader("x-refresh-token", refreshToken) + .addHeader("Content-Type", "application/json") + .post(EMPTY_REQUEST) + .build() + } catch (e: Exception) { + AbsLogger.error(tag, "Could not create refresh request for ${config.name}: ${e.message}") + onResult(RefreshResult.Transient) + return + } + (httpClient ?: defaultClient).newCall(request).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + AbsLogger.error(tag, "Token refresh failed for ${config.name}: ${e.message}") + onResult(RefreshResult.Transient) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { + AbsLogger.error(tag, "Token refresh returned ${it.code} for ${config.name}") + if (it.code != 401 && it.code != 403) { + onResult(RefreshResult.Transient) + return + } + handleRefreshRejected(serverConnectionConfigId) + onResult(RefreshResult.Rejected) + return + } + try { + val user = JSONObject(it.body!!.string()).optJSONObject("user") + val accessToken = user?.optString("accessToken").orEmpty() + if (accessToken.isEmpty()) { + AbsLogger.error(tag, "Refresh response had no access token for ${config.name}") + onResult(RefreshResult.Transient) + return + } + updateTokens(accessToken, user?.optString("refreshToken").orEmpty().ifEmpty { refreshToken }, serverConnectionConfigId) + onResult(RefreshResult.Success(accessToken)) + } catch (e: Exception) { + AbsLogger.error(tag, "Could not parse refresh response for ${config.name}: ${e.message}") + onResult(RefreshResult.Transient) + } + } + } + }) + } + + /** + * Clears only the server that rejected the refresh token; queued downloads can target a non-active server. + * + * Only call this when the server explicitly rejected the refresh token. Transient failures should not log the user out + */ + private fun handleRefreshRejected(serverConnectionConfigId: String) { + // Must not throw: callers still have to report the refresh result to an in-flight request or download. + try { + secureStorage.removeRefreshToken(serverConnectionConfigId) + if (DeviceManager.serverConnectionConfigId != serverConnectionConfigId) return + DeviceManager.serverConnectionConfig = null + DeviceManager.deviceData.lastServerConnectionConfigId = null + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + if (checkAbsDatabaseNotifyListenersInitted()) { + absDatabaseNotifyListeners( + "onTokenRefreshFailure", + JSObject().put("error", "Token refresh failed").put("serverConnectionConfigId", serverConnectionConfigId)) + } + } catch (e: Exception) { + AbsLogger.error(tag, "Could not clear session for $serverConnectionConfigId: ${e.message}") + } + } + + /** + * Updates the stored tokens with new access and refresh tokens + * + * @param newAccessToken The new access token + * @param newRefreshToken The new refresh token (or existing one if not provided) + */ + private fun updateTokens(newAccessToken: String, newRefreshToken: String, serverConnectionConfigId: String) { + try { + // Update the refresh token in secure storage if it's new + if (newRefreshToken != secureStorage.getRefreshToken(serverConnectionConfigId)) { + secureStorage.storeRefreshToken(serverConnectionConfigId, newRefreshToken) + Log.d(tag, "updateTokens: Updated refresh token in secure storage") + } + + // The refreshed connection may be queued in the downloader rather than currently active. + DeviceManager.getServerConnectionConfig(serverConnectionConfigId)?.let { config -> + config.token = newAccessToken + DeviceManager.dbManager.saveDeviceData(DeviceManager.deviceData) + Log.d(tag, "updateTokens: Updated access token in server connection config") + } + + // Send access token to Webview frontend + if (DeviceManager.serverConnectionConfigId == serverConnectionConfigId && checkAbsDatabaseNotifyListenersInitted()) { + val tokenJsObject = JSObject() + tokenJsObject.put("accessToken", newAccessToken) + absDatabaseNotifyListeners("onTokenRefresh", tokenJsObject) + } else { + // Can happen if Webview is never run + Log.i(tag, "AbsDatabaseNotifyListeners is not initialized so cannot send new access token") + } + AbsLogger.info(tag, "updateTokens: Successfully refreshed auth tokens for server ${DeviceManager.serverConnectionConfigString}") + } catch (e: Exception) { + Log.e(tag, "updateTokens: Failed to update tokens", e) + AbsLogger.error(tag, "updateTokens: Failed to refresh auth tokens for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})") + } + } + + /** + * Retries the original request with the new access token + * + * @param originalRequest The original request to retry + * @param newAccessToken The new access token to use + * @param httpClient The HTTP client to use + * @param callback The callback to return the response + */ + private fun retryOriginalRequest(originalRequest: Request, newAccessToken: String, httpClient: OkHttpClient?, callback: (JSObject) -> Unit) { + try { + // Create a new request with the updated authorization header + val newRequest = originalRequest.newBuilder() + .removeHeader("Authorization") + .addHeader("Authorization", "Bearer $newAccessToken") + .build() + + Log.d(tag, "retryOriginalRequest: Retrying request to ${newRequest.url}") + + // Make the retry request + val client = httpClient ?: defaultClient + client.newCall(newRequest).enqueue(object : Callback { + override fun onFailure(call: Call, e: IOException) { + Log.e(tag, "retryOriginalRequest: Failed to retry request", e) + AbsLogger.error(tag, "retryOriginalRequest: Failed to retry request after token refresh for server ${DeviceManager.serverConnectionConfigString} (error: ${e.message})") + val errorObj = JSObject() + errorObj.put("error", "Failed to retry request after token refresh") + callback(errorObj) + } + + override fun onResponse(call: Call, response: Response) { + response.use { + if (!it.isSuccessful) { + Log.e(tag, "retryOriginalRequest: Retry request failed with status ${it.code}") + AbsLogger.error(tag, "retryOriginalRequest: Retry request failed with status ${it.code} for server ${DeviceManager.serverConnectionConfigString}") + val errorObj = JSObject() + errorObj.put("error", "Retry request failed with status ${it.code}") + callback(errorObj) + return + } + + val bodyString = it.body!!.string() + if (bodyString == "OK") { + callback(JSObject()) + } else { + try { + var jsonObj = JSObject() + if (bodyString.startsWith("[")) { + val array = JSArray(bodyString) + jsonObj.put("value", array) + } else { + jsonObj = JSObject(bodyString) + } + callback(jsonObj) + } catch(je:JSONException) { + Log.e(tag, "retryOriginalRequest: Invalid JSON response ${je.localizedMessage} from body $bodyString") + val errorObj = JSObject() + errorObj.put("error", "Invalid response body") + callback(errorObj) + } + } + } + } + }) + + } catch (e: Exception) { + Log.e(tag, "retryOriginalRequest: Unexpected error during retry", e) + AbsLogger.error(tag, "retryOriginalRequest: Unexpected error during retry for server ${DeviceManager.serverConnectionConfigString}") + val errorObj = JSObject() + errorObj.put("error", "Failed to retry request") + callback(errorObj) + } + } + + fun getCurrentUser(cb: (User?) -> Unit) { + getRequest("/api/me", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getCurrentUser Failed") + cb(null) + } else { + val user = jacksonMapper.readValue(it.toString()) + cb(user) + } + } + } + + fun getLibraries(cb: (List) -> Unit) { + val mapper = jacksonMapper + getRequest("/api/libraries?include=stats", null,null) { + val libraries = mutableListOf() + + var array = JSONArray() + if (it.has("libraries")) { // TODO: Server 2.2.9 changed to this + array = it.getJSONArray("libraries") + } else if (it.has("value")) { + array = it.getJSONArray("value") + } + + for (i in 0 until array.length()) { + libraries.add(mapper.readValue(array.get(i).toString())) + } + cb(libraries) + } + } + + fun getLibraryPersonalized(libraryItemId:String, cb: (List?) -> Unit) { + getRequest("/api/libraries/$libraryItemId/personalized", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getLibraryStats Failed") + cb(null) + } else { + val items = mutableListOf() + val array = it.getJSONArray("value") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + cb(items) + } + } + } + + fun getLibraryItem(libraryItemId:String, cb: (LibraryItem?) -> Unit) { + getRequest("/api/items/$libraryItemId?expanded=1", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getLibraryItem Failed") + cb(null) + } else { + val libraryItem = jacksonMapper.readValue(it.toString()) + cb(libraryItem) + } + } + } + + fun getLibraryItemWithProgress(libraryItemId:String, episodeId:String?, cb: (LibraryItem?) -> Unit) { + var requestUrl = "/api/items/$libraryItemId?expanded=1&include=progress" + if (!episodeId.isNullOrEmpty()) requestUrl += "&episode=$episodeId" + getRequest(requestUrl, null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getLibraryItemWithProgress Failed") + cb(null) + } else { + val libraryItem = jacksonMapper.readValue(it.toString()) + cb(libraryItem) + } + } + } + + fun getLibraryItems(libraryId:String, cb: (List) -> Unit) { + getRequest("/api/libraries/$libraryId/items?limit=100&minified=1", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun getLibrarySeries(libraryId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting series") + getRequest("/api/libraries/$libraryId/series?minified=1&sort=name&limit=10000", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun getLibrarySeriesItems(libraryId:String, seriesId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting items for series") + val seriesIdBase64 = Base64.encodeToString(seriesId.toByteArray(), Base64.DEFAULT) + getRequest("/api/libraries/$libraryId/items?minified=1&sort=media.metadata.title&filter=series.${seriesIdBase64}&limit=1000", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun getLibraryAuthors(libraryId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting series") + getRequest("/api/libraries/$libraryId/authors", null, null) { + val items = mutableListOf() + if (it.has("authors")) { + val array = it.getJSONArray("authors") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + }else{ + Log.e(tag, "No results") + } + cb(items) + } + } + + fun getLibraryItemsFromAuthor(libraryId:String, authorId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting author items") + val authorIdBase64 = Base64.encodeToString(authorId.toByteArray(), Base64.DEFAULT) + getRequest("/api/libraries/$libraryId/items?limit=1000&minified=1&filter=authors.${authorIdBase64}&sort=media.metadata.title&collapseseries=1", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + if (item.collapsedSeries != null) { + item.collapsedSeries?.libraryId = libraryId + } + items.add(item) + } + }else{ + Log.e(tag, "No results") + } + cb(items) + } + } + + fun getLibraryCollections(libraryId:String, cb: (List) -> Unit) { + Log.d(tag, "Getting collections") + getRequest("/api/libraries/$libraryId/collections?minified=1&sort=name&limit=1000", null, null) { + val items = mutableListOf() + if (it.has("results")) { + val array = it.getJSONArray("results") + for (i in 0 until array.length()) { + val item = jacksonMapper.readValue(array.get(i).toString()) + items.add(item) + } + } + cb(items) + } + } + + fun getSearchResults(libraryId:String, queryString:String, cb: (LibraryItemSearchResultType?) -> Unit) { + Log.d(tag, "Doing search for library $libraryId") + getRequest("/api/libraries/$libraryId/search?q=$queryString", null, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "getSearchResults Failed") + cb(null) + } else { + val librarySearchResults = jacksonMapper.readValue(it.toString()) + cb(librarySearchResults) + } + } + } + + fun getAllItemsInProgress(cb: (List) -> Unit) { + getRequest("/api/me/items-in-progress", null, null) { + val items = mutableListOf() + if (it.has("libraryItems")) { + val array = it.getJSONArray("libraryItems") + for (i in 0 until array.length()) { + val jsobj = array.get(i) as JSONObject + val itemInProgress = ItemInProgress.makeFromServerObject(jsobj, jacksonMapper) + items.add(itemInProgress) + } + } + cb(items) + } + } + + fun playLibraryItem(libraryItemId:String, episodeId:String?, playItemRequestPayload:PlayItemRequestPayload, cb: (PlaybackSession?) -> Unit) { + val payload = JSObject(jacksonMapper.writeValueAsString(playItemRequestPayload)) + + val endpoint = if (episodeId.isNullOrEmpty()) "/api/items/$libraryItemId/play" else "/api/items/$libraryItemId/play/$episodeId" + postRequest(endpoint, payload, null) { + if (it.has("error")) { + Log.e(tag, it.getString("error") ?: "Play Library Item Failed") + cb(null) + } else { + it.put("serverConnectionConfigId", DeviceManager.serverConnectionConfig?.id) + it.put("serverAddress", DeviceManager.serverAddress) + val playbackSession = jacksonMapper.readValue(it.toString()) + cb(playbackSession) + } + } + } + + fun sendProgressSync(sessionId:String, syncData: MediaProgressSyncData, cb: (Boolean, String?) -> Unit) { + val payload = JSObject(jacksonMapper.writeValueAsString(syncData)) + + postRequest("/api/session/$sessionId/sync", payload, null) { + if (!it.getString("error").isNullOrEmpty()) { + cb(false, it.getString("error")) + } else { + cb(true, null) + } + } + } + + private fun createPartialPlaybackSession(playbackSession: PlaybackSession): ObjectNode { + val json = jacksonMapper.createObjectNode() + json.put("id", playbackSession.id) + json.put("userId", playbackSession.userId) + json.put("libraryItemId", playbackSession.libraryItemId) + json.put("episodeId", playbackSession.episodeId) + json.put("mediaType", playbackSession.mediaType) + json.put("displayTitle", playbackSession.displayTitle) + json.put("displayAuthor", playbackSession.displayAuthor) + json.put("duration", playbackSession.duration) + json.put("playMethod", playbackSession.playMethod) + json.put("startedAt", playbackSession.startedAt) + json.put("updatedAt", playbackSession.updatedAt) + json.put("timeListening", playbackSession.timeListening) + json.put("currentTime", playbackSession.currentTime) + json.put("mediaPlayer", playbackSession.mediaPlayer) + return json + } + + fun sendLocalProgressSync(playbackSession:PlaybackSession, cb: (Boolean, String?) -> Unit) { + val partialSession = createPartialPlaybackSession(playbackSession) + partialSession.set("deviceInfo", jacksonMapper.valueToTree(playbackSession.deviceInfo)) + postRequest("/api/session/local", JSObject(partialSession.toString()), null) { + if (!it.getString("error").isNullOrEmpty()) { + cb(false, it.getString("error")) + } else { + cb(true, null) + } + } + } + + fun updateMediaProgress(libraryItemId:String,episodeId:String?,updatePayload:JSObject, cb: () -> Unit) { + Log.d(tag, "updateMediaProgress $libraryItemId $episodeId $updatePayload") + val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId" + patchRequest(endpoint,updatePayload) { + Log.d(tag, "updateMediaProgress patched progress") + cb() + } + } + + fun getMediaProgress(libraryItemId:String, episodeId:String?, serverConnectionConfig:ServerConnectionConfig?, cb: (MediaProgress?) -> Unit) { + val endpoint = if(episodeId.isNullOrEmpty()) "/api/me/progress/$libraryItemId" else "/api/me/progress/$libraryItemId/$episodeId" + + // TODO: Using ping client here allows for shorter timeout (3 seconds), maybe rename or make diff client for requests requiring quicker response + getRequest(endpoint, pingClient, serverConnectionConfig) { + if (it.has("error")) { + Log.e(tag, "getMediaProgress: Failed to get progress") + cb(null) + } else { + val progress = jacksonMapper.readValue(it.toString()) + cb(progress) + } + } + } + + fun getPlaybackSession(playbackSessionId:String, cb: (PlaybackSession?) -> Unit) { + Log.d(tag, "getPlaybackSession for $playbackSessionId for server ${DeviceManager.serverAddress}") + val endpoint = "/api/session/$playbackSessionId" + getRequest(endpoint, null, null) { + val err = it.getString("error") + if (!err.isNullOrEmpty()) { + cb(null) + } else { + cb(jacksonMapper.readValue(it.toString())) + } + } + } + + fun pingServer(config:ServerConnectionConfig, cb: (Boolean) -> Unit) { + Log.d(tag, "pingServer: Pinging ${config.address}") + getRequest("/ping", pingClient, config) { + val success = it.getString("success") + if (success.isNullOrEmpty()) { + Log.d(tag, "pingServer: Ping ${config.address} Failed") + cb(false) + } else { + Log.d(tag, "pingServer: Ping ${config.address} Successful") + cb(true) + } + } + } + + fun closePlaybackSession(playbackSessionId:String, config:ServerConnectionConfig?, cb: (Boolean) -> Unit) { + Log.d(tag, "closePlaybackSession: playbackSessionId=$playbackSessionId") + postRequest("/api/session/$playbackSessionId/close", null, config) { + cb(true) + } + } + + fun authorize(config:ServerConnectionConfig, cb: (MutableList?) -> Unit) { + Log.d(tag, "authorize: Authorizing ${config.address}") + postRequest("/api/authorize", JSObject(), config) { + val error = it.getString("error") + if (!error.isNullOrEmpty()) { + Log.d(tag, "authorize: Authorize ${config.address} Failed: $error") + cb(null) + } else { + val mediaProgressList:MutableList = mutableListOf() + val user = it.getJSObject("user") + val mediaProgress = user?.getJSONArray("mediaProgress") ?: JSONArray() + for (i in 0 until mediaProgress.length()) { + val mediaProg = jacksonMapper.readValue(mediaProgress.getJSONObject(i).toString()) + mediaProgressList.add(mediaProg) + } + Log.d(tag, "authorize: Authorize ${config.address} Successful") + cb(mediaProgressList) + } + } + } + + fun sendSyncLocalSessions(playbackSessions:List, cb: (Boolean, String?) -> Unit) { + @SuppressLint("HardwareIds") + val deviceId = Settings.Secure.getString(ctx.contentResolver, Settings.Secure.ANDROID_ID) + val deviceInfo = DeviceInfo(deviceId, Build.MANUFACTURER, Build.MODEL, Build.VERSION.SDK_INT, BuildConfig.VERSION_NAME) + + val json = jacksonMapper.createObjectNode(); + json.putArray("sessions").addAll(playbackSessions.map(::createPartialPlaybackSession)) + json.set("deviceInfo", jacksonMapper.valueToTree(deviceInfo)) + AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Sending ${playbackSessions.size} saved local playback sessions to server (${DeviceManager.serverConnectionConfigName})") + + postRequest("/api/session/local-all", JSObject(json.toString()), null) { + if (!it.getString("error").isNullOrEmpty()) { + AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync local sessions. (${it.getString("error")})") + cb(false, it.getString("error")) + } else { + val response = jacksonMapper.readValue(it.toString()) + response.results.forEach { localSessionSyncResult -> + Log.d(tag, "Synced session result ${localSessionSyncResult.id}|${localSessionSyncResult.progressSynced}|${localSessionSyncResult.success}") + + playbackSessions.find { ps -> ps.id == localSessionSyncResult.id }?.let { session -> + if (localSessionSyncResult.progressSynced == true) { + val syncResult = SyncResult(true, true, "Progress synced on server") + MediaEventManager.saveEvent(session, syncResult) + + AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server, server progress was updated for item ${session.mediaItemId}") + } else if (!localSessionSyncResult.success) { + AbsLogger.error("ApiHandler", "sendSyncLocalSessions: Failed to sync session \"${session.displayTitle}\" with server. Error: ${localSessionSyncResult.error}") + } else { + AbsLogger.info("ApiHandler", "sendSyncLocalSessions: Synced session \"${session.displayTitle}\" with server. Server progress was up-to-date for item ${session.mediaItemId}") + } + } + } + cb(true, null) + } + } + } + + fun syncLocalMediaProgressForUser(cb: () -> Unit) { + AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: Server connection ${DeviceManager.serverConnectionConfigName}") + + // Get all local media progress for this server + val allLocalMediaProgress = DeviceManager.dbManager.getAllLocalMediaProgress().filter { it.serverConnectionConfigId == DeviceManager.serverConnectionConfigId } + if (allLocalMediaProgress.isEmpty()) { + AbsLogger.info("ApiHandler", "[ApiHandler] syncLocalMediaProgressForUser: No local media progress to sync") + return cb() + } + + AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Found ${allLocalMediaProgress.size} local media progress") + + getCurrentUser { user -> + if (user == null) { + AbsLogger.error("ApiHandler", "syncLocalMediaProgressForUser: Failed to load user from server (${DeviceManager.serverConnectionConfigName})") + } else { + var numLocalMediaProgressUptToDate = 0 + var numLocalMediaProgressUpdated = 0 + + // Compare server user progress with local progress + user.mediaProgress.forEach { mediaProgress -> + // Get matching local media progress + allLocalMediaProgress.find { it.isMatch(mediaProgress) }?.let { localMediaProgress -> + if (mediaProgress.lastUpdate > localMediaProgress.lastUpdate) { + val updateLogs = mutableListOf() + if (mediaProgress.progress != localMediaProgress.progress) { + updateLogs.add("Updated progress from ${localMediaProgress.progress} to ${mediaProgress.progress}") + } + if (mediaProgress.currentTime != localMediaProgress.currentTime) { + updateLogs.add("Updated currentTime from ${localMediaProgress.currentTime} to ${mediaProgress.currentTime}") + } + if (mediaProgress.isFinished != localMediaProgress.isFinished) { + updateLogs.add("Updated isFinished from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}") + } + if (mediaProgress.ebookProgress != localMediaProgress.ebookProgress) { + updateLogs.add("Updated ebookProgress from ${localMediaProgress.isFinished} to ${mediaProgress.isFinished}") + } + if (updateLogs.isNotEmpty()) { + AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Server progress for item \"${mediaProgress.mediaItemId}\" is more recent than local (server lastUpdate=${mediaProgress.lastUpdate}, local lastUpdate=${localMediaProgress.lastUpdate}). ${updateLogs.joinToString()}") + } + + localMediaProgress.updateFromServerMediaProgress(mediaProgress) + + // Only report sync if progress changed + if (updateLogs.isNotEmpty()) { + MediaEventManager.syncEvent(mediaProgress, "Sync on server connection") + } + DeviceManager.dbManager.saveLocalMediaProgress(localMediaProgress) + numLocalMediaProgressUpdated++ + } else if (localMediaProgress.lastUpdate > mediaProgress.lastUpdate && localMediaProgress.ebookLocation != null && localMediaProgress.ebookLocation != mediaProgress.ebookLocation) { + // Patch ebook progress to server + AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Local progress for ebook item \"${mediaProgress.mediaItemId}\" is more recent than server progress. Local progress last updated ${localMediaProgress.lastUpdate}, server progress last updated ${mediaProgress.lastUpdate}. Sending server request to update ebook progress from ${mediaProgress.ebookProgress} to ${localMediaProgress.ebookProgress}") + val endpoint = "/api/me/progress/${localMediaProgress.libraryItemId}" + val updatePayload = JSObject() + updatePayload.put("ebookLocation", localMediaProgress.ebookLocation) + updatePayload.put("ebookProgress", localMediaProgress.ebookProgress) + updatePayload.put("lastUpdate", localMediaProgress.lastUpdate) + patchRequest(endpoint,updatePayload) { + AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Successfully updated server ebook progress for item item \"${mediaProgress.mediaItemId}\"") + } + } else { + numLocalMediaProgressUptToDate++ + } + } + } + + AbsLogger.info("ApiHandler", "syncLocalMediaProgressForUser: Finishing syncing local media progress with server. $numLocalMediaProgressUptToDate up-to-date, $numLocalMediaProgressUpdated updated") + } + cb() + } + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt new file mode 100644 index 0000000..3ee79ea --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadService.kt @@ -0,0 +1,102 @@ +package com.audiobookshelf.app.services + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import com.audiobookshelf.app.R +import com.audiobookshelf.app.models.DownloadItemPart + +/** Android-owned foreground lifecycle for transfers that must outlive the WebView and Activity. */ +class DownloadService : Service() { + override fun onCreate() { + super.onCreate() + createChannel() + startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing) + DownloadServiceHost.attachService(this) + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + when (intent?.action) { + ACTION_CANCEL -> DownloadServiceHost.cancelAll(this) + else -> { + startForegroundWithType(DownloadServiceHost.notificationStrings(this).preparing) + DownloadServiceHost.startWork(this) + } + } + return START_STICKY + } + + override fun onDestroy() { + DownloadServiceHost.detachService(this) + super.onDestroy() + } + + override fun onBind(intent: Intent?): IBinder? = null + + fun onPartUpdate(part: DownloadItemPart) { + val strings = DownloadServiceHost.notificationStrings(this) + val text = + if (part.waitingForSpace) strings.waitingForStorage + else strings.downloadingFile.replace("{0}", part.filename) + val progress = part.progress.coerceIn(0L, 100L).toInt() + val notification = notification(text, progress, part.fileSize > 0L) + (getSystemService(NOTIFICATION_SERVICE) as NotificationManager).notify(NOTIFICATION_ID, notification) + } + + fun onQueueChanged(hasWork: Boolean) { + if (!hasWork) { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + + private fun startForegroundWithType(text: String) { + val notification = notification(text) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun notification(text: String, progress: Int = 0, determinate: Boolean = false): Notification { + val cancelIntent = PendingIntent.getService( + this, 1, Intent(this, DownloadService::class.java).setAction(ACTION_CANCEL), pendingIntentFlags()) + return NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(R.drawable.icon) + .setContentTitle(DownloadServiceHost.notificationStrings(this).downloads) + .setContentText(text) + .setOnlyAlertOnce(true) + .setOngoing(true) + .setProgress(100, progress, !determinate) + .addAction(0, DownloadServiceHost.notificationStrings(this).cancel, cancelIntent) + .build() + } + + private fun createChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel( + NotificationChannel( + CHANNEL_ID, + DownloadServiceHost.notificationStrings(this).downloads, + NotificationManager.IMPORTANCE_LOW)) + } + + private fun pendingIntentFlags(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + + companion object { + private const val CHANNEL_ID = "downloads" + private const val NOTIFICATION_ID = 11 + private const val ACTION_CANCEL = "com.audiobookshelf.app.download.CANCEL" + fun intent(context: Context) = Intent(context, DownloadService::class.java) + } +} diff --git a/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt new file mode 100644 index 0000000..1bf1f8c --- /dev/null +++ b/android/app/src/main/java/com/audiobookshelf/app/services/DownloadServiceHost.kt @@ -0,0 +1,213 @@ +package com.audiobookshelf.app.services + +import android.content.Context +import androidx.core.content.ContextCompat +import com.audiobookshelf.app.device.FolderScanner +import com.audiobookshelf.app.managers.DbManager +import com.audiobookshelf.app.managers.DownloadItemManager +import com.audiobookshelf.app.managers.IncompleteDownloadCleanup +import com.audiobookshelf.app.models.DownloadItem +import com.audiobookshelf.app.plugins.AbsLogger +import com.getcapacitor.JSObject +import java.util.Collections +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch + +/** Shared process owner used by the foreground service and the Capacitor bridge. */ +object DownloadServiceHost { + enum class ExistingDownloadResult { NOT_FOUND, ACTIVE, RETRIED, SERVICE_START_FAILED } + + data class NotificationStrings( + val preparing: String, + val downloadingFile: String, + val waitingForStorage: String, + val downloads: String, + val cancel: String + ) + + private var manager: DownloadItemManager? = null + private var bridgeEmitter: DownloadItemManager.DownloadEventEmitter = NoopEmitter + @Volatile private var service: DownloadService? = null + @Volatile private var bridgeReady = false + private val deferredCompletions = Collections.synchronizedList(mutableListOf()) + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private var restoreJob: Job? = null + + @Synchronized + fun ensure(context: Context): DownloadItemManager { + if (manager == null) { + val appContext = context.applicationContext + DbManager.initialize(appContext) + manager = DownloadItemManager(FolderScanner(appContext), appContext, ForwardingEmitter) + restoreJob = scope.launch { + IncompleteDownloadCleanup.cleanupExpired(appContext) + manager!!.restoreQueue() + onRestoreComplete(appContext) + } + } + return manager!! + } + + /** Attaches the frontend after restored queue items have been emitted. */ + @Synchronized + fun attachBridge(context: Context, emitter: DownloadItemManager.DownloadEventEmitter) { + bridgeReady = false + bridgeEmitter = emitter + ensure(context).setEventEmitter(ForwardingEmitter) + bridgeReady = true + val completions = synchronized(deferredCompletions) { + deferredCompletions.toList().also { deferredCompletions.clear() } + } + completions.forEach(bridgeEmitter::onDownloadItemComplete) + } + + @Synchronized + fun detachBridge() { + bridgeReady = false + bridgeEmitter = NoopEmitter + } + + fun enqueue(context: Context, item: DownloadItem, callback: (String?) -> Unit) { + val queue = ensure(context) + scope.launch { + restoreJob?.join() + queue.addDownloadItem(item) + if (startService(context)) callback(null) + else callback("Unable to start the Android download service") + } + } + + fun retryExisting( + context: Context, + downloadItemId: String, + callback: (ExistingDownloadResult) -> Unit + ) { + val queue = ensure(context) + scope.launch { + restoreJob?.join() + val existing = queue.downloadItemQueue.find { it.id == downloadItemId } + if (existing == null) { + callback(ExistingDownloadResult.NOT_FOUND) + } else if (!queue.retryDownloadItem(downloadItemId)) { + callback(ExistingDownloadResult.ACTIVE) + } else if (startService(context)) { + callback(ExistingDownloadResult.RETRIED) + } else { + callback(ExistingDownloadResult.SERVICE_START_FAILED) + } + } + } + + fun cancelAll(context: Context) { + val queue = ensure(context) + scope.launch { + restoreJob?.join() + queue.cancelAll() + } + } + + fun setNotificationStrings( + context: Context, + preparing: String, + downloadingFile: String, + waitingForStorage: String, + downloads: String, + cancel: String + ) { + context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + .edit() + .putString(KEY_PREPARING, preparing) + .putString(KEY_DOWNLOADING_FILE, downloadingFile) + .putString(KEY_WAITING_FOR_STORAGE, waitingForStorage) + .putString(KEY_DOWNLOADS, downloads) + .putString(KEY_CANCEL, cancel) + .apply() + } + + fun notificationStrings(context: Context): NotificationStrings { + val preferences = context.getSharedPreferences(NOTIFICATION_PREFERENCES, Context.MODE_PRIVATE) + return NotificationStrings( + preferences.getString(KEY_PREPARING, DEFAULT_PREPARING) ?: DEFAULT_PREPARING, + preferences.getString(KEY_DOWNLOADING_FILE, DEFAULT_DOWNLOADING_FILE) + ?: DEFAULT_DOWNLOADING_FILE, + preferences.getString(KEY_WAITING_FOR_STORAGE, DEFAULT_WAITING_FOR_STORAGE) + ?: DEFAULT_WAITING_FOR_STORAGE, + preferences.getString(KEY_DOWNLOADS, DEFAULT_DOWNLOADS) ?: DEFAULT_DOWNLOADS, + preferences.getString(KEY_CANCEL, DEFAULT_CANCEL) ?: DEFAULT_CANCEL) + } + + fun attachService(downloadService: DownloadService) { + synchronized(this) { service = downloadService } + startWork(downloadService) + } + + @Synchronized + fun detachService(downloadService: DownloadService) { + if (service === downloadService) service = null + } + + fun startWork(context: Context) { + val queue = ensure(context) + scope.launch { + restoreJob?.join() + queue.resumeWork() + } + } + + private fun onRestoreComplete(context: Context) { + val attachedService = synchronized(this) { service } + if (attachedService != null) { + manager?.resumeWork() + } else if (bridgeReady && manager?.hasWork() == true) { + startService(context) + } + } + + private fun startService(context: Context): Boolean { + return try { + ContextCompat.startForegroundService(context, DownloadService.intent(context)) + true + } catch (e: RuntimeException) { + AbsLogger.error(TAG, "Could not start download foreground service: ${e.message}") + false + } + } + + private object ForwardingEmitter : DownloadItemManager.DownloadEventEmitter { + override fun onDownloadItem(downloadItem: DownloadItem) { bridgeEmitter.onDownloadItem(downloadItem) } + override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) { + if (bridgeReady) bridgeEmitter.onDownloadItemPartUpdate(downloadItemPart) + service?.onPartUpdate(downloadItemPart) + } + override fun onDownloadItemComplete(jsobj: JSObject) { + if (bridgeReady) bridgeEmitter.onDownloadItemComplete(jsobj) else deferredCompletions.add(jsobj) + } + override fun onQueueChanged(hasWork: Boolean) { + bridgeEmitter.onQueueChanged(hasWork) + service?.onQueueChanged(hasWork) + } + } + + private object NoopEmitter : DownloadItemManager.DownloadEventEmitter { + override fun onDownloadItem(downloadItem: DownloadItem) = Unit + override fun onDownloadItemPartUpdate(downloadItemPart: com.audiobookshelf.app.models.DownloadItemPart) = Unit + override fun onDownloadItemComplete(jsobj: JSObject) = Unit + override fun onQueueChanged(hasWork: Boolean) = Unit + } + + private const val NOTIFICATION_PREFERENCES = "download_notifications" + private const val KEY_PREPARING = "preparing" + private const val KEY_DOWNLOADING_FILE = "downloading_file" + private const val KEY_WAITING_FOR_STORAGE = "waiting_for_storage" + private const val KEY_DOWNLOADS = "downloads" + private const val KEY_CANCEL = "cancel" + private const val DEFAULT_PREPARING = "Preparing downloads" + private const val DEFAULT_DOWNLOADING_FILE = "Downloading {0}" + private const val DEFAULT_WAITING_FOR_STORAGE = "Waiting for available storage" + private const val DEFAULT_DOWNLOADS = "Downloads" + private const val DEFAULT_CANCEL = "Cancel" + private const val TAG = "DownloadServiceHost" +} diff --git a/android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml new file mode 100644 index 0000000..0c3acf8 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_downloaddone.xml @@ -0,0 +1,18 @@ + + + + + + diff --git a/android/app/src/main/res/drawable-anydpi-v24/exo_icon_localaudio.xml b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_localaudio.xml new file mode 100644 index 0000000..87354c3 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi-v24/exo_icon_localaudio.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/exo_icon_fastforward.xml b/android/app/src/main/res/drawable-anydpi/exo_icon_fastforward.xml new file mode 100644 index 0000000..c9238b3 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/exo_icon_fastforward.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/exo_icon_rewind.xml b/android/app/src/main/res/drawable-anydpi/exo_icon_rewind.xml new file mode 100644 index 0000000..9e70127 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/exo_icon_rewind.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_0_5x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_0_5x.xml new file mode 100644 index 0000000..dc91ae5 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_0_5x.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_0x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_0x.xml new file mode 100644 index 0000000..e0c261c --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_0x.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_2x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_2x.xml new file mode 100644 index 0000000..6590aab --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_2x.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_5x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_5x.xml new file mode 100644 index 0000000..1ed9793 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_1_5x.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_2_0x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_2_0x.xml new file mode 100644 index 0000000..54ff98a --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_2_0x.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/ic_play_speed_3_0x.xml b/android/app/src/main/res/drawable-anydpi/ic_play_speed_3_0x.xml new file mode 100644 index 0000000..6d834aa --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/ic_play_speed_3_0x.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/icon_library_folder.xml b/android/app/src/main/res/drawable-anydpi/icon_library_folder.xml new file mode 100644 index 0000000..2eb0c3c --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/icon_library_folder.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml b/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml new file mode 100644 index 0000000..6fa1e71 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/md_clock_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/drawable-anydpi/skip_next_24.xml b/android/app/src/main/res/drawable-anydpi/skip_next_24.xml new file mode 100644 index 0000000..8c8a748 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/skip_next_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-anydpi/skip_previous_24.xml b/android/app/src/main/res/drawable-anydpi/skip_previous_24.xml new file mode 100644 index 0000000..ee35a17 --- /dev/null +++ b/android/app/src/main/res/drawable-anydpi/skip_previous_24.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/android/app/src/main/res/drawable-hdpi/exo_icon_downloaddone.png b/android/app/src/main/res/drawable-hdpi/exo_icon_downloaddone.png new file mode 100644 index 0000000..f7b92c7 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/exo_icon_downloaddone.png differ diff --git a/android/app/src/main/res/drawable-hdpi/exo_icon_fastforward.png b/android/app/src/main/res/drawable-hdpi/exo_icon_fastforward.png new file mode 100644 index 0000000..be2f1f7 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/exo_icon_fastforward.png differ diff --git a/android/app/src/main/res/drawable-hdpi/exo_icon_localaudio.png b/android/app/src/main/res/drawable-hdpi/exo_icon_localaudio.png new file mode 100644 index 0000000..81972ef Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/exo_icon_localaudio.png differ diff --git a/android/app/src/main/res/drawable-hdpi/exo_icon_rewind.png b/android/app/src/main/res/drawable-hdpi/exo_icon_rewind.png new file mode 100644 index 0000000..893b7ff Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/exo_icon_rewind.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_play_speed_0_5x.png b/android/app/src/main/res/drawable-hdpi/ic_play_speed_0_5x.png new file mode 100644 index 0000000..ca5a548 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_play_speed_0_5x.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_play_speed_1_0x.png b/android/app/src/main/res/drawable-hdpi/ic_play_speed_1_0x.png new file mode 100644 index 0000000..cf490b4 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_play_speed_1_0x.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_play_speed_1_2x.png b/android/app/src/main/res/drawable-hdpi/ic_play_speed_1_2x.png new file mode 100644 index 0000000..9c8aefb Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_play_speed_1_2x.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_play_speed_1_5x.png b/android/app/src/main/res/drawable-hdpi/ic_play_speed_1_5x.png new file mode 100644 index 0000000..f5619d8 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_play_speed_1_5x.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_play_speed_2_0x.png b/android/app/src/main/res/drawable-hdpi/ic_play_speed_2_0x.png new file mode 100644 index 0000000..2be3d42 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_play_speed_2_0x.png differ diff --git a/android/app/src/main/res/drawable-hdpi/ic_play_speed_3_0x.png b/android/app/src/main/res/drawable-hdpi/ic_play_speed_3_0x.png new file mode 100644 index 0000000..4c1928d Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/ic_play_speed_3_0x.png differ diff --git a/android/app/src/main/res/drawable-hdpi/icon_library_folder.png b/android/app/src/main/res/drawable-hdpi/icon_library_folder.png new file mode 100644 index 0000000..eee3872 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/icon_library_folder.png differ diff --git a/android/app/src/main/res/drawable-hdpi/md_account_outline.png b/android/app/src/main/res/drawable-hdpi/md_account_outline.png new file mode 100644 index 0000000..cddf791 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/md_account_outline.png differ diff --git a/android/app/src/main/res/drawable-hdpi/md_clock_outline.png b/android/app/src/main/res/drawable-hdpi/md_clock_outline.png new file mode 100644 index 0000000..a0dc9c1 Binary files /dev/null and b/android/app/src/main/res/drawable-hdpi/md_clock_outline.png differ diff --git a/android/app/src/main/res/drawable-mdpi/exo_icon_downloaddone.png b/android/app/src/main/res/drawable-mdpi/exo_icon_downloaddone.png new file mode 100644 index 0000000..b1242a0 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/exo_icon_downloaddone.png differ diff --git a/android/app/src/main/res/drawable-mdpi/exo_icon_fastforward.png b/android/app/src/main/res/drawable-mdpi/exo_icon_fastforward.png new file mode 100644 index 0000000..01d4907 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/exo_icon_fastforward.png differ diff --git a/android/app/src/main/res/drawable-mdpi/exo_icon_localaudio.png b/android/app/src/main/res/drawable-mdpi/exo_icon_localaudio.png new file mode 100644 index 0000000..a3724bb Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/exo_icon_localaudio.png differ diff --git a/android/app/src/main/res/drawable-mdpi/exo_icon_rewind.png b/android/app/src/main/res/drawable-mdpi/exo_icon_rewind.png new file mode 100644 index 0000000..430fc8d Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/exo_icon_rewind.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_play_speed_0_5x.png b/android/app/src/main/res/drawable-mdpi/ic_play_speed_0_5x.png new file mode 100644 index 0000000..536f9d9 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_play_speed_0_5x.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_play_speed_1_0x.png b/android/app/src/main/res/drawable-mdpi/ic_play_speed_1_0x.png new file mode 100644 index 0000000..d1aa14f Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_play_speed_1_0x.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_play_speed_1_2x.png b/android/app/src/main/res/drawable-mdpi/ic_play_speed_1_2x.png new file mode 100644 index 0000000..39a4c78 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_play_speed_1_2x.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_play_speed_1_5x.png b/android/app/src/main/res/drawable-mdpi/ic_play_speed_1_5x.png new file mode 100644 index 0000000..6e387d0 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_play_speed_1_5x.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_play_speed_2_0x.png b/android/app/src/main/res/drawable-mdpi/ic_play_speed_2_0x.png new file mode 100644 index 0000000..f1385c2 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_play_speed_2_0x.png differ diff --git a/android/app/src/main/res/drawable-mdpi/ic_play_speed_3_0x.png b/android/app/src/main/res/drawable-mdpi/ic_play_speed_3_0x.png new file mode 100644 index 0000000..68d4f68 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/ic_play_speed_3_0x.png differ diff --git a/android/app/src/main/res/drawable-mdpi/icon_library_folder.png b/android/app/src/main/res/drawable-mdpi/icon_library_folder.png new file mode 100644 index 0000000..6a91eea Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/icon_library_folder.png differ diff --git a/android/app/src/main/res/drawable-mdpi/md_account_outline.png b/android/app/src/main/res/drawable-mdpi/md_account_outline.png new file mode 100644 index 0000000..3e21f51 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/md_account_outline.png differ diff --git a/android/app/src/main/res/drawable-mdpi/md_clock_outline.png b/android/app/src/main/res/drawable-mdpi/md_clock_outline.png new file mode 100644 index 0000000..4320332 Binary files /dev/null and b/android/app/src/main/res/drawable-mdpi/md_clock_outline.png differ diff --git a/android/app/src/main/res/drawable-nodpi/media_player_widget_preview.png b/android/app/src/main/res/drawable-nodpi/media_player_widget_preview.png new file mode 100644 index 0000000..27a07ef Binary files /dev/null and b/android/app/src/main/res/drawable-nodpi/media_player_widget_preview.png differ diff --git a/android/app/src/main/res/drawable-v21/app_widget_background.xml b/android/app/src/main/res/drawable-v21/app_widget_background.xml new file mode 100644 index 0000000..878e041 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/app_widget_background.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml b/android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml new file mode 100644 index 0000000..cd72ec5 --- /dev/null +++ b/android/app/src/main/res/drawable-v21/app_widget_inner_view_background.xml @@ -0,0 +1,11 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable-xhdpi/exo_icon_downloaddone.png b/android/app/src/main/res/drawable-xhdpi/exo_icon_downloaddone.png new file mode 100644 index 0000000..a89dc73 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/exo_icon_downloaddone.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/exo_icon_fastforward.png b/android/app/src/main/res/drawable-xhdpi/exo_icon_fastforward.png new file mode 100644 index 0000000..eb32716 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/exo_icon_fastforward.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/exo_icon_localaudio.png b/android/app/src/main/res/drawable-xhdpi/exo_icon_localaudio.png new file mode 100644 index 0000000..7317d3a Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/exo_icon_localaudio.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/exo_icon_rewind.png b/android/app/src/main/res/drawable-xhdpi/exo_icon_rewind.png new file mode 100644 index 0000000..e046793 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/exo_icon_rewind.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_play_speed_0_5x.png b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_0_5x.png new file mode 100644 index 0000000..f062a13 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_0_5x.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_0x.png b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_0x.png new file mode 100644 index 0000000..025ad26 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_0x.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_2x.png b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_2x.png new file mode 100644 index 0000000..b01cf91 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_2x.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_5x.png b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_5x.png new file mode 100644 index 0000000..fab44af Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_1_5x.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_play_speed_2_0x.png b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_2_0x.png new file mode 100644 index 0000000..2eac485 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_2_0x.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/ic_play_speed_3_0x.png b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_3_0x.png new file mode 100644 index 0000000..fb7b3d9 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/ic_play_speed_3_0x.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/icon_library_folder.png b/android/app/src/main/res/drawable-xhdpi/icon_library_folder.png new file mode 100644 index 0000000..9033543 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/icon_library_folder.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/md_account_outline.png b/android/app/src/main/res/drawable-xhdpi/md_account_outline.png new file mode 100644 index 0000000..9ca5650 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/md_account_outline.png differ diff --git a/android/app/src/main/res/drawable-xhdpi/md_clock_outline.png b/android/app/src/main/res/drawable-xhdpi/md_clock_outline.png new file mode 100644 index 0000000..39f8d71 Binary files /dev/null and b/android/app/src/main/res/drawable-xhdpi/md_clock_outline.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/exo_icon_downloaddone.png b/android/app/src/main/res/drawable-xxhdpi/exo_icon_downloaddone.png new file mode 100644 index 0000000..5499dfa Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/exo_icon_downloaddone.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/exo_icon_fastforward.png b/android/app/src/main/res/drawable-xxhdpi/exo_icon_fastforward.png new file mode 100644 index 0000000..4a48cc4 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/exo_icon_fastforward.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/exo_icon_localaudio.png b/android/app/src/main/res/drawable-xxhdpi/exo_icon_localaudio.png new file mode 100644 index 0000000..62f2445 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/exo_icon_localaudio.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/exo_icon_rewind.png b/android/app/src/main/res/drawable-xxhdpi/exo_icon_rewind.png new file mode 100644 index 0000000..a317ede Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/exo_icon_rewind.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_0_5x.png b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_0_5x.png new file mode 100644 index 0000000..1aa6aa9 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_0_5x.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_0x.png b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_0x.png new file mode 100644 index 0000000..f39d666 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_0x.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_2x.png b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_2x.png new file mode 100644 index 0000000..78534fd Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_2x.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_5x.png b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_5x.png new file mode 100644 index 0000000..99eef85 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_1_5x.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_2_0x.png b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_2_0x.png new file mode 100644 index 0000000..30382a6 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_2_0x.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_3_0x.png b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_3_0x.png new file mode 100644 index 0000000..e35c0cc Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/ic_play_speed_3_0x.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/icon_library_folder.png b/android/app/src/main/res/drawable-xxhdpi/icon_library_folder.png new file mode 100644 index 0000000..fe9ee61 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/icon_library_folder.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/md_account_outline.png b/android/app/src/main/res/drawable-xxhdpi/md_account_outline.png new file mode 100644 index 0000000..e0fcbf9 Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/md_account_outline.png differ diff --git a/android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png b/android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png new file mode 100644 index 0000000..c1c30bb Binary files /dev/null and b/android/app/src/main/res/drawable-xxhdpi/md_clock_outline.png differ diff --git a/android/app/src/main/res/drawable/abs_audiobookshelf.xml b/android/app/src/main/res/drawable/abs_audiobookshelf.xml new file mode 100644 index 0000000..f6fef02 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_audiobookshelf.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_authors.xml b/android/app/src/main/res/drawable/abs_authors.xml new file mode 100644 index 0000000..9f7e9a4 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_authors.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_book_1.xml b/android/app/src/main/res/drawable/abs_book_1.xml new file mode 100644 index 0000000..fc946fa --- /dev/null +++ b/android/app/src/main/res/drawable/abs_book_1.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_books_1.xml b/android/app/src/main/res/drawable/abs_books_1.xml new file mode 100644 index 0000000..e991059 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_books_1.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_books_2.xml b/android/app/src/main/res/drawable/abs_books_2.xml new file mode 100644 index 0000000..64b308e --- /dev/null +++ b/android/app/src/main/res/drawable/abs_books_2.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_columns.xml b/android/app/src/main/res/drawable/abs_columns.xml new file mode 100644 index 0000000..a51ab6a --- /dev/null +++ b/android/app/src/main/res/drawable/abs_columns.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_database.xml b/android/app/src/main/res/drawable/abs_database.xml new file mode 100644 index 0000000..18fa418 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_database.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_file_picture.xml b/android/app/src/main/res/drawable/abs_file_picture.xml new file mode 100644 index 0000000..0d3f534 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_file_picture.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_headphones.xml b/android/app/src/main/res/drawable/abs_headphones.xml new file mode 100644 index 0000000..2e228cf --- /dev/null +++ b/android/app/src/main/res/drawable/abs_headphones.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_heart.xml b/android/app/src/main/res/drawable/abs_heart.xml new file mode 100644 index 0000000..06647dc --- /dev/null +++ b/android/app/src/main/res/drawable/abs_heart.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_microphone_1.xml b/android/app/src/main/res/drawable/abs_microphone_1.xml new file mode 100644 index 0000000..e2c0721 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_microphone_1.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_microphone_2.xml b/android/app/src/main/res/drawable/abs_microphone_2.xml new file mode 100644 index 0000000..0db2ffe --- /dev/null +++ b/android/app/src/main/res/drawable/abs_microphone_2.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_microphone_3.xml b/android/app/src/main/res/drawable/abs_microphone_3.xml new file mode 100644 index 0000000..25d3a65 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_microphone_3.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_music.xml b/android/app/src/main/res/drawable/abs_music.xml new file mode 100644 index 0000000..4f473bc --- /dev/null +++ b/android/app/src/main/res/drawable/abs_music.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_podcast.xml b/android/app/src/main/res/drawable/abs_podcast.xml new file mode 100644 index 0000000..261e3db --- /dev/null +++ b/android/app/src/main/res/drawable/abs_podcast.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_radio.xml b/android/app/src/main/res/drawable/abs_radio.xml new file mode 100644 index 0000000..980f938 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_radio.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_rocket.xml b/android/app/src/main/res/drawable/abs_rocket.xml new file mode 100644 index 0000000..e667330 --- /dev/null +++ b/android/app/src/main/res/drawable/abs_rocket.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_rss.xml b/android/app/src/main/res/drawable/abs_rss.xml new file mode 100644 index 0000000..21259ee --- /dev/null +++ b/android/app/src/main/res/drawable/abs_rss.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/abs_star.xml b/android/app/src/main/res/drawable/abs_star.xml new file mode 100644 index 0000000..6da979c --- /dev/null +++ b/android/app/src/main/res/drawable/abs_star.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/exo_icon_fastforward.xml b/android/app/src/main/res/drawable/exo_icon_fastforward.xml new file mode 100644 index 0000000..a9301c5 --- /dev/null +++ b/android/app/src/main/res/drawable/exo_icon_fastforward.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/app/src/main/res/drawable/exo_icon_rewind.xml b/android/app/src/main/res/drawable/exo_icon_rewind.xml new file mode 100644 index 0000000..13480ca --- /dev/null +++ b/android/app/src/main/res/drawable/exo_icon_rewind.xml @@ -0,0 +1,5 @@ + + + diff --git a/android/app/src/main/res/drawable/icon.png b/android/app/src/main/res/drawable/icon.png new file mode 100644 index 0000000..7f93ef8 Binary files /dev/null and b/android/app/src/main/res/drawable/icon.png differ diff --git a/android/app/src/main/res/drawable/icon_32.png b/android/app/src/main/res/drawable/icon_32.png new file mode 100644 index 0000000..6667625 Binary files /dev/null and b/android/app/src/main/res/drawable/icon_32.png differ diff --git a/android/app/src/main/res/drawable/icon_monochrome.xml b/android/app/src/main/res/drawable/icon_monochrome.xml new file mode 100644 index 0000000..594c52c --- /dev/null +++ b/android/app/src/main/res/drawable/icon_monochrome.xml @@ -0,0 +1,9 @@ + + + diff --git a/android/app/src/main/res/drawable/md_account_outline.xml b/android/app/src/main/res/drawable/md_account_outline.xml new file mode 100644 index 0000000..cf8a5c6 --- /dev/null +++ b/android/app/src/main/res/drawable/md_account_outline.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/drawable/md_book_multiple_outline.xml b/android/app/src/main/res/drawable/md_book_multiple_outline.xml new file mode 100644 index 0000000..d215e9e --- /dev/null +++ b/android/app/src/main/res/drawable/md_book_multiple_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/md_book_open_blank_variant_outline.xml b/android/app/src/main/res/drawable/md_book_open_blank_variant_outline.xml new file mode 100644 index 0000000..09cb77a --- /dev/null +++ b/android/app/src/main/res/drawable/md_book_open_blank_variant_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/md_clock_outline.xml b/android/app/src/main/res/drawable/md_clock_outline.xml new file mode 100644 index 0000000..6fa1e71 --- /dev/null +++ b/android/app/src/main/res/drawable/md_clock_outline.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/android/app/src/main/res/drawable/md_telescope.xml b/android/app/src/main/res/drawable/md_telescope.xml new file mode 100644 index 0000000..8e8c575 --- /dev/null +++ b/android/app/src/main/res/drawable/md_telescope.xml @@ -0,0 +1 @@ + diff --git a/android/app/src/main/res/drawable/skip_next_24.xml b/android/app/src/main/res/drawable/skip_next_24.xml new file mode 100644 index 0000000..d84d1d3 --- /dev/null +++ b/android/app/src/main/res/drawable/skip_next_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/drawable/skip_previous_24.xml b/android/app/src/main/res/drawable/skip_previous_24.xml new file mode 100644 index 0000000..3149ba4 --- /dev/null +++ b/android/app/src/main/res/drawable/skip_previous_24.xml @@ -0,0 +1,10 @@ + + + diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..aeba359 --- /dev/null +++ b/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,13 @@ + + + + + diff --git a/android/app/src/main/res/layout/media_player_widget.xml b/android/app/src/main/res/layout/media_player_widget.xml new file mode 100644 index 0000000..a71a7d3 --- /dev/null +++ b/android/app/src/main/res/layout/media_player_widget.xml @@ -0,0 +1,108 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..80bcc6f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..80bcc6f --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..014e97a Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png new file mode 100644 index 0000000..65a25ec Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..2d994eb Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..014e97a Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..b617e90 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png new file mode 100644 index 0000000..03b19bd Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..99ca551 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..b617e90 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..7b77b8c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png new file mode 100644 index 0000000..f5e8f5a Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..a760ef3 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..7b77b8c Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..66b62f6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..a94ffea Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..eb8c00a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..66b62f6 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..6a89597 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png new file mode 100644 index 0000000..37dcd48 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_background.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 0000000..9fb8263 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..6a89597 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/raw/bell.mp3 b/android/app/src/main/res/raw/bell.mp3 new file mode 100644 index 0000000..c14bd84 Binary files /dev/null and b/android/app/src/main/res/raw/bell.mp3 differ diff --git a/android/app/src/main/res/values-night-v31/themes.xml b/android/app/src/main/res/values-night-v31/themes.xml new file mode 100644 index 0000000..db9d77f --- /dev/null +++ b/android/app/src/main/res/values-night-v31/themes.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/android/app/src/main/res/values-v21/styles.xml b/android/app/src/main/res/values-v21/styles.xml new file mode 100644 index 0000000..91b5608 --- /dev/null +++ b/android/app/src/main/res/values-v21/styles.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values-v31/styles.xml b/android/app/src/main/res/values-v31/styles.xml new file mode 100644 index 0000000..3a0135c --- /dev/null +++ b/android/app/src/main/res/values-v31/styles.xml @@ -0,0 +1,16 @@ + + + + + + diff --git a/android/app/src/main/res/values-v31/themes.xml b/android/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000..8ffe2e9 --- /dev/null +++ b/android/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/values/attrs.xml b/android/app/src/main/res/values/attrs.xml new file mode 100644 index 0000000..3f37ec3 --- /dev/null +++ b/android/app/src/main/res/values/attrs.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..6039c19 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,7 @@ + + #FFE1F5FE + #FF81D4FA + #FF039BE5 + #FF01579B + #232323 + diff --git a/android/app/src/main/res/values/dimens.xml b/android/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..4db8c59 --- /dev/null +++ b/android/app/src/main/res/values/dimens.xml @@ -0,0 +1,10 @@ + + + + + 0dp + + \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..6672b67 --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + + Audiobookshelf + Audiobookshelf + com.audiobookshelf.app + audiobookshelf + Add widget + Simple widget for audiobookshelf playback + Jump Forward + Jump Backward + Skip Forward + Skip Backward + Change Playback Speed + diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..4f1668d --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,42 @@ + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..fc7585c --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,17 @@ + + + + + + diff --git a/android/app/src/main/res/xml/automotive_app_desc.xml b/android/app/src/main/res/xml/automotive_app_desc.xml new file mode 100644 index 0000000..e9cf4c1 --- /dev/null +++ b/android/app/src/main/res/xml/automotive_app_desc.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/android/app/src/main/res/xml/config.xml b/android/app/src/main/res/xml/config.xml new file mode 100644 index 0000000..0886a47 --- /dev/null +++ b/android/app/src/main/res/xml/config.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..e009819 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/android/app/src/main/res/xml/media_player_widget_info.xml b/android/app/src/main/res/xml/media_player_widget_info.xml new file mode 100644 index 0000000..dc35257 --- /dev/null +++ b/android/app/src/main/res/xml/media_player_widget_info.xml @@ -0,0 +1,14 @@ + + diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..3ff858a --- /dev/null +++ b/android/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/android/app/src/main/res/xml/samsung_cover_widget_info.xml b/android/app/src/main/res/xml/samsung_cover_widget_info.xml new file mode 100644 index 0000000..c664752 --- /dev/null +++ b/android/app/src/main/res/xml/samsung_cover_widget_info.xml @@ -0,0 +1,4 @@ + + diff --git a/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java new file mode 100644 index 0000000..0297327 --- /dev/null +++ b/android/app/src/test/java/com/getcapacitor/myapp/ExampleUnitTest.java @@ -0,0 +1,18 @@ +package com.getcapacitor.myapp; + +import static org.junit.Assert.*; + +import org.junit.Test; + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see Testing documentation + */ +public class ExampleUnitTest { + + @Test + public void addition_isCorrect() throws Exception { + assertEquals(4, 2 + 2); + } +} diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..333f6fb --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,33 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext.kotlin_version = '2.0.0' + + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.google.gms:google-services:4.4.2' + classpath 'com.android.tools.build:gradle:8.8.2' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +apply from: "variables.gradle" + +allprojects { + repositories { + google() + mavenCentral() + } +} + +tasks.register('clean', Delete) { + delete rootProject.layout.buildDirectory +} + + diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle new file mode 100644 index 0000000..2c9a795 --- /dev/null +++ b/android/capacitor.settings.gradle @@ -0,0 +1,36 @@ +// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN +include ':capacitor-android' +project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/android/capacitor') + +include ':webnativellc-capacitor-filesharer' +project(':webnativellc-capacitor-filesharer').projectDir = new File('../node_modules/@webnativellc/capacitor-filesharer/android') + +include ':capacitor-community-keep-awake' +project(':capacitor-community-keep-awake').projectDir = new File('../node_modules/@capacitor-community/keep-awake/android') + +include ':capacitor-community-volume-buttons' +project(':capacitor-community-volume-buttons').projectDir = new File('../node_modules/@capacitor-community/volume-buttons/android') + +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + +include ':capacitor-browser' +project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android') + +include ':capacitor-clipboard' +project(':capacitor-clipboard').projectDir = new File('../node_modules/@capacitor/clipboard/android') + +include ':capacitor-dialog' +project(':capacitor-dialog').projectDir = new File('../node_modules/@capacitor/dialog/android') + +include ':capacitor-haptics' +project(':capacitor-haptics').projectDir = new File('../node_modules/@capacitor/haptics/android') + +include ':capacitor-network' +project(':capacitor-network').projectDir = new File('../node_modules/@capacitor/network/android') + +include ':capacitor-preferences' +project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') + +include ':capacitor-status-bar' +project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android') diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000..0176d04 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,36 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +#org.gradle.jvmargs=-Xmx1536m +org.gradle.jvmargs=-Dfile.encoding=UTF-8 \ + --add-opens jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED \ + --add-opens jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true + +kapt.use.worker.api=false + +android.buildfeatures.buildconfig=true + +org.gradle.warning.mode=all diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..ccebba7 Binary files /dev/null and b/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bc60d1f --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-all.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 0000000..79a61d4 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..339420e --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,9 @@ +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.9.0" +} + +include ':app' +include ':capacitor-cordova-android-plugins' +project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/') + +apply from: 'capacitor.settings.gradle' diff --git a/android/variables.gradle b/android/variables.gradle new file mode 100644 index 0000000..eff2a59 --- /dev/null +++ b/android/variables.gradle @@ -0,0 +1,24 @@ +ext { + minSdkVersion = 24 + compileSdkVersion = 36 + targetSdkVersion = 36 + androidxActivityVersion = '1.9.2' + androidxAppCompatVersion = '1.7.0' + androidxCoordinatorLayoutVersion = '1.3.0' + androidxCoreVersion = '1.15.0' + androidxFragmentVersion = '1.8.4' + junitVersion = '4.13.2' + androidxJunitVersion = '1.2.1' + androidxEspressoCoreVersion = '3.6.1' + cordovaAndroidVersion = '10.1.1' + androidx_core_ktx_version = '1.16.0' + androidx_media_version = '1.7.0' + exoplayer_version = '2.18.7' + glide_version = '4.16.0' + junit_version = '4.13.2' + kotlin_version = '2.1.0' + kotlin_coroutines_version = '1.10.1' + test_runner_version = '1.1.0' + coreSplashScreenVersion = '1.0.1' + androidxWebkitVersion = '1.12.1' +} diff --git a/assets/WrappingMarquee.js b/assets/WrappingMarquee.js new file mode 100644 index 0000000..3bbef67 --- /dev/null +++ b/assets/WrappingMarquee.js @@ -0,0 +1,119 @@ +class WrappingMarquee { + #scrollDelay = 2000 + #scrollSpeed = 30 + + /** + * @param {HTMLElement} el + */ + constructor(el) { + this.el = el + /** @type {HTMLElement} */ + this.pEl = el?.firstElementChild + + this.innerText = '' + this.isScrolling = false + + /** @type {NodeJS.Timeout} */ + this.timer = null + /** @type {number} */ + this.animationId = null + } + + /** + * Transparent gradient mask shown when text is scrolling left and overflowing right + * + * @param {boolean} showLeft + */ + setMask(showLeft) { + if (!this.el) return + this.el.style.maskImage = showLeft ? 'linear-gradient(90deg, transparent 0%, #fff 10%, #000 90%, transparent)' : 'linear-gradient(90deg, #000 90%, transparent)' + } + + startScroll() { + if (this.isScrolling) { + console.warn('Already scrolling') + return + } + + this.isScrolling = true + this.setMask(true) + + let textScrollAmount = this.el.scrollWidth + this.pEl.textContent = this.innerText + '\u00A0'.repeat(15) // \u00A0 = non-breaking space + let totalScrollAmount = this.el.scrollWidth + let scrollDuration = totalScrollAmount * this.#scrollSpeed + + this.pEl.textContent = this.pEl.textContent + this.innerText + + let done = false + let start, previousTimeStamp + + const step = (timeStamp) => { + if (start === undefined) { + start = timeStamp + } + const elapsed = timeStamp - start + + if (this.isScrolling && previousTimeStamp !== timeStamp) { + const amountToMove = Math.min((elapsed / scrollDuration) * totalScrollAmount, totalScrollAmount) + this.pEl.style.transform = `translateX(-${amountToMove}px)` + if (amountToMove === totalScrollAmount) done = true + if (amountToMove > textScrollAmount) this.setMask(false) + } + + if (!this.isScrolling || done) { + // canceled or done + this.isScrolling = false + this.pEl.style.transform = 'translateX(0px)' + this.pEl.innerText = this.innerText + this.setMask(false) + if (done) { + this.startTimer() + } + } else if (elapsed < scrollDuration) { + // step + previousTimeStamp = timeStamp + this.animationId = window.requestAnimationFrame(step) + } + } + this.animationId = window.requestAnimationFrame(step) + } + + startTimer() { + clearTimeout(this.timer) + this.timer = setTimeout(() => { + this.startScroll() + }, this.#scrollDelay) + } + + reset() { + clearTimeout(this.timer) + this.timer = null + this.isScrolling = false + window.cancelAnimationFrame(this.animationId) + } + + /** + * Initialize and start marquee if text overflows container + * resets the marquee if already active + * + * @param {string} innerText + */ + init(innerText) { + if (!this.el || !this.pEl) return + + this.reset() + + this.innerText = innerText + this.pEl.innerText = innerText + this.pEl.style.transform = 'translateX(0px)' + + if (this.el.scrollWidth > this.el.clientWidth) { + this.setMask(false) + this.startTimer() + } else { + this.el.style.maskImage = '' + } + } +} +export default WrappingMarquee diff --git a/assets/absicons.css b/assets/absicons.css new file mode 100644 index 0000000..f8bef3e --- /dev/null +++ b/assets/absicons.css @@ -0,0 +1,121 @@ +@font-face { + font-family: 'absicons'; + src: url('~static/fonts/absicons/absicons.eot?2jfq33'); + src: url('~static/fonts/absicons/absicons.eot?2jfq33#iefix') format('embedded-opentype'), + url('~static/fonts/absicons/absicons.ttf?2jfq33') format('truetype'), + url('~static/fonts/absicons/absicons.woff?2jfq33') format('woff'), + url('~static/fonts/absicons/absicons.svg?2jfq33#absicons') format('svg'); + font-weight: normal; + font-style: normal; + font-display: block; +} + +.abs-icons { + /* use !important to prevent issues with browser extensions that change fonts */ + font-family: 'absicons' !important; + font-style: normal; + font-weight: normal; + font-variant: normal; + text-transform: none; + line-height: 1; + + /* Better Font Rendering =========== */ + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.icon-list:before { + content: "\e907"; +} + +.icon-home:before { + content: "\e909"; +} + +.icon-authors:before { + content: "\e90a"; +} + +.icon-books-1:before { + content: "\e905"; +} + +.icon-microphone-1:before { + content: "\e902"; +} + +.icon-radio:before { + content: "\e903"; +} + +.icon-podcast:before { + content: "\e904"; +} + +.icon-audiobookshelf:before { + content: "\e900"; +} + +.icon-database:before { + content: "\e906"; +} + +.icon-microphone-2:before { + content: "\e901"; +} + +.icon-columns:before { + content: "\e90b"; +} + +.icon-headphones:before { + content: "\e910"; +} + +.icon-music:before { + content: "\e911"; +} + +.icon-video:before { + content: "\e914"; +} + +.icon-microphone-3:before { + content: "\e91e"; +} + +.icon-book-1:before { + content: "\e91f"; +} + +.icon-books-2:before { + content: "\e920"; +} + +.icon-file-picture:before { + content: "\e927"; +} + +.icon-database-1:before { + content: "\e964"; +} + +.icon-rocket:before { + content: "\e9a5"; +} + +.icon-power:before { + content: "\e9b5"; +} + +.icon-star:before { + content: "\e9d9"; +} + +.icon-heart:before { + content: "\e9da"; +} + +.icon-rss:before { + content: "\ea9b"; +} \ No newline at end of file diff --git a/assets/app.css b/assets/app.css new file mode 100644 index 0000000..dde1e0a --- /dev/null +++ b/assets/app.css @@ -0,0 +1,171 @@ +@import './tailwind.css'; +@import './fonts.css'; +@import './defaultStyles.css'; +@import './absicons.css'; +@import './transitions.css'; + +* { + -webkit-touch-callout: none; + -webkit-user-select: none; + user-select: none; +} + +input, +textarea { + -webkit-touch-callout: auto; + -webkit-user-select: auto; + user-select: auto; +} + +.layout-wrapper { + height: calc(100vh - env(safe-area-inset-top)); + min-height: calc(100vh - env(safe-area-inset-top)); + max-height: calc(100vh - env(safe-area-inset-top)); + margin-top: env(safe-area-inset-top); +} + +#content { + height: calc(100% - 64px); + min-height: calc(100% - 64px); + max-height: calc(100% - 64px); +} + +#content.playerOpen { + height: calc(100% - 184px); + min-height: calc(100% - 184px); + max-height: calc(100% - 184px); +} + +#bookshelf { + height: calc(100% - 48px); + min-height: calc(100% - 48px); +} + +.box-shadow-sm { + box-shadow: 0px 3px 6px #11111170; +} + +.box-shadow-md { + box-shadow: 2px 8px 6px #111111aa; +} + +.box-shadow-lg-up { + box-shadow: 0px -12px 8px #111111ee; +} + +.box-shadow-xl { + box-shadow: 2px 14px 8px #111111aa; +} + +.box-shadow-book { + box-shadow: 4px 1px 8px #11111166, -4px 1px 8px #11111166, 1px -4px 8px #11111166; +} + +.box-shadow-progressbar { + box-shadow: 0px -1px 4px rgb(62, 50, 2, 0.5); +} + +.shadow-height { + height: calc(100% - 4px); +} + +.bookshelfRow { + background-image: url(/wood_panels.jpg); +} + +.bookshelfDivider { + background: rgb(149, 119, 90); + background: linear-gradient(180deg, rgba(149, 119, 90, 1) 0%, rgba(103, 70, 37, 1) 17%, rgba(103, 70, 37, 1) 88%, rgba(71, 48, 25, 1) 100%); + box-shadow: 2px 10px 8px #1111117e; +} + +.altBookshelfDivider { + background: rgb(38 38 38); + /*background: linear-gradient(180deg, rgba(191, 193, 195, 1) 0%, rgb(156, 158, 159) 17%, rgb(114, 115, 117) 88%, rgb(120, 120, 122) 100%);*/ + box-shadow: 2px 10px 8px #1111117e; +} + +/* +Bookshelf Label +*/ +.categoryPlacard { + letter-spacing: 1px; +} + +.shinyBlack { + background-color: #2d3436; + background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%); + border-color: rgba(255, 244, 182, 0.6); + border-style: solid; + color: #fce3a6; +} + +.altBookshelfLabel { + background-color: #2d3436; + background-image: linear-gradient(315deg, #19191a 0%, rgb(15, 15, 15) 74%); + border-color: rgb(255, 255, 255); + border-style: solid; + color: #ffffff; +} + +.cover-bg { + width: calc(100% + 40px); + height: calc(100% + 40px); + top: -20px; + left: -20px; + background-size: 100% 100%; + background-position: center; + opacity: 1; + filter: blur(20px); +} + +.episode-subtitle { + word-break: break-word; + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + line-height: 16px; + /* fallback */ + max-height: 32px; + /* fallback */ + -webkit-line-clamp: 2; + /* number of lines to show */ + -webkit-box-orient: vertical; +} + +.line-clamp-2 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.line-clamp-4 { + overflow: hidden; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; +} + +.tracksTable { + border-collapse: collapse; + width: 100%; + border: 1px solid rgb(var(--color-secondary)); +} + +.tracksTable tr:nth-child(even) { + background-color: rgb(var(--color-secondary)); +} + +.tracksTable tr { + background-color: rgb(var(--color-bg)); +} + +.tracksTable td { + padding: 8px 8px; +} + +.tracksTable th { + padding: 4px 8px; + font-size: 0.75rem; +} diff --git a/assets/defaultStyles.css b/assets/defaultStyles.css new file mode 100644 index 0000000..51d6b39 --- /dev/null +++ b/assets/defaultStyles.css @@ -0,0 +1,67 @@ +/* + + This is for setting regular html styles for places where embedding HTML will be + like podcast episode descriptions. Otherwise TailwindCSS will have stripped all default markup. + +*/ + +.default-style p { + display: block; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; +} + +.default-style a { + text-decoration: none; + color: #5985ff; +} + +.default-style ul { + display: block; + list-style: circle; + list-style-type: disc; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; + padding-inline-start: 40px; +} + +.default-style ol { + display: block; + list-style: decimal; + list-style-type: decimal; + margin-block-start: 1em; + margin-block-end: 1em; + margin-inline-start: 0px; + margin-inline-end: 0px; + padding-inline-start: 40px; +} + +.default-style li { + display: list-item; + text-align: -webkit-match-parent; +} + +.default-style li::marker { + unicode-bidi: isolate; + font-variant-numeric: tabular-nums; + text-transform: none; + text-indent: 0px !important; + text-align: start !important; + text-align-last: start !important; +} + +.default-style.less-spacing p { + margin-block-start: 0; +} + +.default-style.less-spacing ul { + margin-block-start: 0; +} + +.default-style.less-spacing ol { + margin-block-start: 0; +} diff --git a/assets/ebooks/basic.js b/assets/ebooks/basic.js new file mode 100644 index 0000000..16be6af --- /dev/null +++ b/assets/ebooks/basic.js @@ -0,0 +1,506 @@ +/* +Calibres stylesheet +*/ + +export default ` +@charset "UTF-8"; + +/* + Calibre styles +*/ +.arabic { + display: block; + list-style-type: decimal; + margin-bottom: 1em; + margin-right: 0; + margin-top: 1em; + text-align: justify + } +.attribution { + display: block; + font-size: 1em; + line-height: 1.2; + text-align: left; + margin: 0.3em 0 + } +.big { + font-size: 1.375em; + line-height: 1.2 + } +.big1 { + font-size: 1em + } +.block { + display: block; + text-align: justify; + margin: 1em 1em 2em + } +.block1 { + display: block; + text-align: justify; + margin: 1em 4em + } +.block2 { + display: block; + text-align: justify; + margin: 1em 1em 1em 2em + } +.bullet { + display: block; + list-style-type: disc; + margin-bottom: 1em; + margin-right: 0; + margin-top: 1em; + text-align: disc + } +.calibre { + background-color: #000007; + display: block; + font-family: Charis, "Times New Roman", Verdana, Arial; + font-size: 1.125em; + line-height: 1.2; + padding-left: 0; + padding-right: 0; + text-align: center; + margin: 0 5pt + } +.calibre1 { + display: block + } +.calibre2 { + height: auto; + width: auto + } +.calibre3:not(strong) { + display: block; + font-family: Charis, "Times New Roman", Verdana, Arial; + font-size: 1.125em; + line-height: 1.2; + padding-left: 0; + padding-right: 0; + margin: 0 5pt + } +.calibre4 { + font-weight: bold + } +.calibre5 { + font-style: italic + } +.calibre6 { + background-color: #FFF; + display: block; + font-family: Charis, "Times New Roman", Verdana, Arial; + font-size: 1.125em; + line-height: 1.2; + padding-left: 0; + padding-right: 0; + text-align: center; + margin: 0 5pt + } +.calibre7 { + display: list-item + } +.calibre8 { + font-size: 1em; + line-height: 1.2; + vertical-align: super + } +.calibre9 { + border-collapse: separate; + border-spacing: 2px; + display: table; + margin-bottom: 0; + margin-top: 0; + text-indent: 0 + } +.calibre10 { + display: table-row; + vertical-align: middle + } +.calibre11 { + display: table-cell; + text-align: right; + vertical-align: inherit; + padding: 1px + } +.calibre12 { + display: table-cell; + text-align: left; + vertical-align: inherit; + padding: 1px + } +.calibre13 { + height: 1em; + width: auto + } +.calibre14 { + font-size: 0.88889em; + line-height: 1.2; + vertical-align: super + } +.calibre15 { + font-size: 1em; + line-height: 1.2; + vertical-align: sub + } +.calibre16 { + display: block; + list-style-type: decimal; + margin-bottom: 1em; + margin-right: 0; + margin-top: 1em + } +.calibre17 { + display: block; + font-size: 1.125em; + font-weight: bold; + line-height: 1.2; + margin: 0.83em 0 + } +.center { + display: block; + text-align: center; + margin: 1em 0 + } +.center1 { + display: block; + font-size: 1em; + font-weight: bold; + line-height: 1.2; + text-align: center; + margin: -2em 0 3em + } +.center2 { + display: block; + font-size: 1em; + font-weight: bold; + line-height: 1.2; + text-align: center; + margin: 2em 0 1em + } +.center3 { + display: block; + text-align: center; + margin: -1em 0 1em + } +.center4 { + display: block; + text-align: center; + text-indent: 3%; + margin: 1em 0 + } +.chapter { + display: block; + font-size: 1.125em; + font-weight: bold; + line-height: 2em; + text-align: center; + margin: 2em 0 1em + } +.chapter1 { + display: block; + font-size: 0.88889em; + line-height: 1.2; + margin-left: 0.5em; + margin-right: 0.5em; + margin-top: 2em + } +.chapter2 { + display: block; + font-size: 1.125em; + font-weight: bold; + line-height: 2em; + text-align: center; + margin: 2em 0 3em + } +.copyright { + display: block; + font-size: 0.88889em; + line-height: 1.2; + margin-top: 4em; + text-align: center + } +.dedication { + display: block; + font-size: 0.88889em; + line-height: 1.2; + margin-top: 4em + } +.dropcaps { + float: left; + font-size: 3.4375rem; + line-height: 50px; + margin-right: 0.09em; + margin-top: -0.05em; + padding-top: 1px + } +.dropcaps1 { + float: left; + font-size: 3.4375rem; + line-height: 50px; + margin-right: 0.09em; + margin-top: 0.15em; + padding-top: 1px + } +.extract { + display: block; + text-align: justify; + margin: 2em 0 0.3em + } +.extract1 { + display: block; + text-align: justify; + text-indent: 3%; + margin: 2em 0 0.3em + } +.extract2 { + display: block; + text-align: justify; + margin: 1em 0 0.3em + } +.footnote { + border-bottom-style: solid; + border-bottom-width: 0; + border-left-style: solid; + border-left-width: 0; + border-right-style: solid; + border-right-width: 0; + border-top-style: solid; + border-top-width: 1px; + display: block; + font-size: 1em; + line-height: 1.2; + margin-top: 2 em + } +.footnote1 { + display: block; + text-align: justify; + margin: 0.3em 0 0.3em 2 + } +.footnote2 { + border-bottom-style: solid; + border-bottom-width: 0; + border-left-style: solid; + border-left-width: 0; + border-right-style: solid; + border-right-width: 0; + border-top-style: solid; + border-top-width: 1px; + display: block; + font-size: 0.88889em; + line-height: 1.2; + margin-top: 2 em + } +.hanging { + display: block; + font-size: 0.88889em; + line-height: 1.2; + text-align: left; + text-indent: -1em; + margin: 0.5em 0 0.3em 1em + } +.hanging1 { + display: block; + font-size: 0.88889em; + line-height: 1.2; + text-align: left; + text-indent: -1em; + margin: 0.5em 0 0.3em 1.5em + } +.hanging2 { + display: block; + font-size: 1em; + line-height: 1.2; + text-indent: -1em; + margin: 0.5em 0 0.3em 1em + } +.hanging3 { + display: block; + font-size: 1em; + line-height: 1.2; + text-align: left; + text-indent: 1em; + margin: 0.1em 0 0.3em 1em + } +.hanging4 { + display: block; + font-size: 1em; + line-height: 1.2; + text-align: left; + text-indent: 0.1em; + margin: 0.1em 0 0.3em 1em + } +a.hlink { + text-decoration: none + } +.indent { + display: block; + text-align: justify; + text-indent: 1em; + margin: 0.3em 0 + } +.line { + border-top: currentColor solid 1px; + border-bottom: currentColor solid 1px + } +.loweralpha { + display: block; + list-style-type: lower-alpha; + margin-bottom: 1em; + margin-right: 0; + margin-top: 1em; + text-align: justify + } +.none { + display: block; + list-style-type: none; + margin-bottom: 1em; + margin-right: 0; + margin-top: 1em; + text-align: justify + } +.none1 { + display: block; + list-style-type: none; + margin-bottom: 0; + margin-right: 0; + margin-top: 0; + text-align: justify + } +.nonindent { + display: block; + text-align: justify; + margin: 0.3em 0 + } +.nonindent1 { + display: block; + font-size: 1.125em; + line-height: 1.2; + text-indent: -1em; + margin: 0.5em 0 0.3em 0.1em + } +.nonindent2 { + display: block; + font-size: 1.125em; + line-height: 1.2; + text-indent: -1em; + margin: 0.5em 0 0.3em -0.5em + } +.nonindent3 { + display: block; + text-align: justify; + text-indent: 3%; + margin: 0.3em 0 + } +.part { + display: block; + font-size: 1em; + font-weight: bold; + line-height: 2em; + text-align: center; + margin: 4em 0 1em + } +.preface { + display: block; + font-size: 0.88889em; + line-height: 1.2; + margin-left: 2em; + margin-right: 2em; + text-align: justify + } +.pubhlink { + color: green; + text-decoration: none + } +.right { + display: block; + text-align: right; + margin: 0.3em 0 + } +.section { + display: block; + font-size: 1.125em; + font-weight: bold; + line-height: 1.2; + text-align: center; + margin: 2em 0 0.5em + } +.section1 { + display: block; + font-size: 1.125em; + font-weight: bold; + line-height: 1.2; + text-align: left; + margin: 2em 0 0.3em + } +.section2 { + display: block; + font-size: 1em; + font-weight: bold; + line-height: 1.2; + text-align: left; + margin: 2em 0 0.3em 1em + } +.small { + font-size: 0.66667em + } +.small1 { + font-size: 0.75em + } +.subchapter { + display: block; + font-size: 1.125em; + font-weight: bold; + line-height: 1.2; + margin: 1em 0 + } +.textbox { + background-color: #E4E4E4; + display: block; + line-height: 1.5em; + margin-bottom: 2em; + margin-top: 2em; + text-align: justify; + border-top: currentColor double 2px; + border-bottom: currentColor double 2px + } +.textbox1 { + display: block; + text-align: justify; + margin: 0.3em 0.5em 0.3em 0.8em + } +.textbox2 { + display: block; + text-align: justify; + text-indent: 1em; + margin: 0.3em 0.5em + } +.textbox3 { + display: block; + text-align: justify; + text-indent: 3%; + margin: 0.3em 0.5em 0.3em 0.8em + } +.titlepage { + display: block; + margin-left: -0.4em; + margin-top: 1.2em + } +.toc { + display: block; + font-size: 1em; + line-height: 1.2; + text-align: center + } +.toc1 { + display: block; + font-size: 1em; + font-weight: bold; + line-height: 1.2; + text-align: center; + margin: 0.67em 0 3em + } +.underline { + text-decoration: underline + } +` \ No newline at end of file diff --git a/assets/ebooks/htmlParser.js b/assets/ebooks/htmlParser.js new file mode 100644 index 0000000..21bcc78 --- /dev/null +++ b/assets/ebooks/htmlParser.js @@ -0,0 +1,248 @@ +/* +This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src +*/ + +export const isTitle = ( + line, + isContainDI = false, + isContainChapter = false, + isContainCHAPTER = false +) => { + return ( + line.length < 30 && + line.indexOf("[") === -1 && + line.indexOf("(") === -1 && + (line.startsWith("CHAPTER") || + line.startsWith("Chapter") || + line.startsWith("序章") || + line.startsWith("前言") || + line.startsWith("声明") || + line.startsWith("聲明") || + line.startsWith("写在前面的话") || + line.startsWith("后记") || + line.startsWith("楔子") || + line.startsWith("后序") || + line.startsWith("寫在前面的話") || + line.startsWith("後記") || + line.startsWith("後序") || + /(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test( + line + ) || + (line.startsWith("第") && startWithDI(line)) || + (line.startsWith("卷") && startWithJUAN(line)) || + startWithRomanNum(line) || + (!isContainDI && + !isContainChapter && + !isContainCHAPTER && + line.indexOf("第") > -1 && + (line[line.indexOf("第") - 1] === " " || + line[line.indexOf("第") - 1] === " " || + line[line.indexOf("第") - 1] === "、" || + line[line.indexOf("第") - 1] === ":" || + line[line.indexOf("第") - 1] === ":") && + startWithDI(line.substr(line.indexOf("第")))) || + (!isContainDI && + !isContainChapter && + !isContainCHAPTER && + line.indexOf(" ") && + startWithNumAndSpace(line)) || + (!isContainDI && + !isContainChapter && + !isContainCHAPTER && + line.indexOf(" ") && + startWithNumAndSpace(line)) || + (!isContainDI && + !isContainChapter && + !isContainCHAPTER && + line.indexOf("、") && + startWithNumAndPause(line)) || + (!isContainDI && + !isContainChapter && + !isContainCHAPTER && + line.indexOf(":") && + startWithNumAndColon(line)) || + (!isContainDI && + !isContainChapter && + !isContainCHAPTER && + line.indexOf(":") && + startWithNumAndColon(line))) + ); +}; +const startWithDI = (line) => { + let keywords = [ + "章", + "节", + "回", + "節", + "卷", + "部", + "輯", + "辑", + "話", + "集", + "话", + "篇", + ]; + let flag = false; + for (let i = 0; i < keywords.length; i++) { + if ( + (line.indexOf(keywords[i]) > -1 && + (line[line.indexOf(keywords[i]) + 1] === " " || + line[line.indexOf(keywords[i]) + 1] === " " || + line[line.indexOf(keywords[i]) + 1] === "、" || + line[line.indexOf(keywords[i]) + 1] === ":" || + line[line.indexOf(keywords[i]) + 1] === ":")) || + !line[line.indexOf(keywords[i]) + 1] + ) { + if ( + /^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test( + line.substring(1, line.indexOf(keywords[i])).trim() + ) || + /^\d+$/.test(line.substring(1, line.indexOf(keywords[i])).trim()) + ) { + flag = true; + } + if (flag) break; + } + } + return flag; +}; +const startWithJUAN = (line) => { + if ( + /^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test( + line.substring(1, line.indexOf(" ")) + ) || + /^\d+$/.test(line.substring(1, line.indexOf(" "))) + ) + return true; + if ( + /^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test( + line.substring(1, line.indexOf(" ")) + ) || + /^\d+$/.test(line.substring(1, line.indexOf(" "))) + ) + return true; + if ( + /^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test( + line.substring(1) + ) || + /^\d+$/.test(line.substring(1)) + ) + return true; + return false; +}; +const startWithRomanNum = (line) => { + if ( + /(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test( + line.substring(0, line.indexOf(" ")) + ) + ) + return true; + if ( + /(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test( + line.substring(0, line.indexOf(".")) + ) + ) + return true; + if ( + /(?=[MDCLXVI])M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$/.test( + line.trim() + ) + ) + return true; + return false; +}; +const startWithNumAndSpace = (line) => { + if ( + /^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test( + line.substring(0, line.indexOf(" ")) + ) + ) + return true; + if ( + /^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test( + line.substring(0, line.indexOf(" ")) + ) + ) + return true; + + if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true; + if (/^\d+$/.test(line.substring(0, line.indexOf(" ")))) return true; + return false; +}; +const startWithNumAndColon = (line) => { + if ( + /^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test( + line.substring(0, line.indexOf(":")) + ) + ) + return true; + if ( + /^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test( + line.substring(0, line.indexOf(":")) + ) + ) + return true; + + if (/^\d+$/.test(line.substring(0, line.indexOf(":")))) return true; + if (/^\d+$/.test(line.substring(0, line.indexOf(":")))) return true; + return false; +}; +const startWithNumAndPause = (line) => { + if ( + /^[\u4e00\u4e8c\u4e09\u56db\u4e94\u516d\u4e03\u516b\u4e5d\u5341\u767e\u5343\u4e07\u842c]+$/.test( + line.substring(0, line.indexOf("、")) + ) + ) + return true; + + if (/^\d+$/.test(line.substring(0, line.indexOf("、")))) return true; + return false; +}; + + +class HtmlParser { + bookDoc; + contentList; + contentTitleList; + constructor(bookDoc) { + this.bookDoc = bookDoc; + this.contentList = []; + this.contentTitleList = []; + this.getContent(bookDoc); + } + getContent(bookDoc) { + this.contentList = Array.from( + bookDoc.querySelectorAll("h1,h2,h3,h4,h5,b,font") + ).filter((item, index) => { + return isTitle(item.innerText.trim()); + }); + + for (let i = 0; i < this.contentList.length; i++) { + let random = Math.floor(Math.random() * 900000) + 100000; + this.contentTitleList.push({ + label: this.contentList[i].innerText, + id: "title" + random, + href: "#title" + random, + subitems: [], + }); + } + for (let i = 0; i < this.contentList.length; i++) { + this.contentList[i].id = this.contentTitleList[i].id; + } + } + getAnchoredDoc() { + return this.bookDoc; + } + getContentList() { + return this.contentTitleList.filter((item, index) => { + if (index > 0) { + return item.label !== this.contentTitleList[index - 1].label; + } else { + return true; + } + }); + } +} + +export default HtmlParser; diff --git a/assets/ebooks/mobi.js b/assets/ebooks/mobi.js new file mode 100644 index 0000000..58c6779 --- /dev/null +++ b/assets/ebooks/mobi.js @@ -0,0 +1,450 @@ +/* +This is borrowed from koodo-reader https://github.com/troyeguo/koodo-reader/tree/master/src +*/ + +function ab2str(buf) { + if (buf instanceof ArrayBuffer) { + buf = new Uint8Array(buf); + } + return new TextDecoder("utf-8").decode(buf); +} + +var domParser = new DOMParser(); + +class Buffer { + capacity; + fragment_list; + imageArray; + cur_fragment; + constructor(capacity) { + this.capacity = capacity; + this.fragment_list = []; + this.imageArray = []; + this.cur_fragment = new Fragment(capacity); + this.fragment_list.push(this.cur_fragment); + } + write(byte) { + var result = this.cur_fragment.write(byte); + if (!result) { + this.cur_fragment = new Fragment(this.capacity); + this.fragment_list.push(this.cur_fragment); + this.cur_fragment.write(byte); + } + } + get(idx) { + var fi = 0; + while (fi < this.fragment_list.length) { + var frag = this.fragment_list[fi]; + if (idx < frag.size) { + return frag.get(idx); + } + idx -= frag.size; + fi += 1; + } + return null; + } + size() { + var s = 0; + for (var i = 0; i < this.fragment_list.length; i++) { + s += this.fragment_list[i].size; + } + return s; + } + shrink() { + var total_buffer = new Uint8Array(this.size()); + var offset = 0; + for (var i = 0; i < this.fragment_list.length; i++) { + var frag = this.fragment_list[i]; + if (frag.full()) { + total_buffer.set(frag.buffer, offset); + } else { + total_buffer.set(frag.buffer.slice(0, frag.size), offset); + } + offset += frag.size; + } + return total_buffer; + } +} + +var copagesne_uint8array = function (buffers) { + var total_size = 0; + for (let i = 0; i < buffers.length; i++) { + var buffer = buffers[i]; + total_size += buffer.length; + } + var total_buffer = new Uint8Array(total_size); + var offset = 0; + for (let i = 0; i < buffers.length; i++) { + buffer = buffers[i]; + total_buffer.set(buffer, offset); + offset += buffer.length; + } + return total_buffer; +}; + +class Fragment { + buffer; + capacity; + size; + constructor(capacity) { + this.buffer = new Uint8Array(capacity); + this.capacity = capacity; + this.size = 0; + } + + write(byte) { + if (this.size >= this.capacity) { + return false; + } + this.buffer[this.size] = byte; + this.size += 1; + return true; + } + full() { + return this.size === this.capacity; + } + get(idx) { + return this.buffer[idx]; + } +} + +var uncompression_lz77 = function (data) { + var length = data.length; + var offset = 0; // Current offset into data + var buffer = new Buffer(data.length); + + while (offset < length) { + var char = data[offset]; + offset += 1; + + if (char === 0) { + buffer.write(char); + } else if (char <= 8) { + for (var i = offset; i < offset + char; i++) { + buffer.write(data[i]); + } + offset += char; + } else if (char <= 0x7f) { + buffer.write(char); + } else if (char <= 0xbf) { + var next = data[offset]; + offset += 1; + var distance = (((char << 8) | next) >> 3) & 0x7ff; + var lz_length = (next & 0x7) + 3; + + var buffer_size = buffer.size(); + for (let i = 0; i < lz_length; i++) { + buffer.write(buffer.get(buffer_size - distance)); + buffer_size += 1; + } + } else { + buffer.write(32); + buffer.write(char ^ 0x80); + } + } + return buffer; +}; + +class MobiFile { + view; + buffer; + offset; + header; + palm_header; + mobi_header; + reclist; + constructor(data) { + this.view = new DataView(data); + this.buffer = this.view.buffer; + this.offset = 0; + this.header = null; + } + + parse() { } + + getUint8() { + var v = this.view.getUint8(this.offset); + this.offset += 1; + return v; + } + + getUint16() { + var v = this.view.getUint16(this.offset); + this.offset += 2; + return v; + } + + getUint32() { + var v = this.view.getUint32(this.offset); + this.offset += 4; + return v; + } + + getStr(size) { + var v = ab2str(this.buffer.slice(this.offset, this.offset + size)); + this.offset += size; + return v; + } + + skip(size) { + this.offset += size; + } + + setoffset(_of) { + this.offset = _of; + } + + get_record_extrasize(data, flags) { + var pos = data.length - 1; + var extra = 0; + for (var i = 15; i > 0; i--) { + if (flags & (1 << i)) { + var res = this.buffer_get_varlen(data, pos); + var size = res[0]; + var l = res[1]; + pos = res[2]; + pos -= size - l; + extra += size; + } + } + if (flags & 1) { + var a = data[pos]; + extra += (a & 0x3) + 1; + } + return extra; + } + + // data should be uint8array + buffer_get_varlen(data, pos) { + var l = 0; + var size = 0; + var byte_count = 0; + var mask = 0x7f; + var stop_flag = 0x80; + var shift = 0; + for (var i = 0; ; i++) { + var byte = data[pos]; + size |= (byte & mask) << shift; + shift += 7; + l += 1; + byte_count += 1; + pos -= 1; + + var to_stop = byte & stop_flag; + if (byte_count >= 4 || to_stop > 0) { + break; + } + } + return [size, l, pos]; + } + // 读出文本内容 + read_text() { + var text_end = this.palm_header.record_count; + var buffers = []; + for (var i = 1; i <= text_end; i++) { + buffers.push(this.read_text_record(i)); + } + var all = copagesne_uint8array(buffers); + return ab2str(all); + } + + read_text_record(i) { + var flags = this.mobi_header.extra_flags; + var begin = this.reclist[i].offset; + var end = this.reclist[i + 1].offset; + + var data = new Uint8Array(this.buffer.slice(begin, end)); + var ex = this.get_record_extrasize(data, flags); + + data = new Uint8Array(this.buffer.slice(begin, end - ex)); + if (this.palm_header.compression === 2) { + var buffer = uncompression_lz77(data); + return buffer.shrink(); + } else { + return data; + } + } + // 从buffer中读出image + read_image(idx) { + var first_image_idx = this.mobi_header.first_image_idx; + var begin = this.reclist[first_image_idx + idx].offset; + var end = this.reclist[first_image_idx + idx + 1].offset; + var data = new Uint8Array(this.buffer.slice(begin, end)); + return new Blob([data.buffer]); + } + + load() { + this.header = this.load_pdbheader(); + this.reclist = this.load_reclist(); + this.load_record0(); + } + + load_pdbheader() { + var header = {}; + header.name = this.getStr(32); + header.attr = this.getUint16(); + header.version = this.getUint16(); + header.ctime = this.getUint32(); + header.mtime = this.getUint32(); + header.btime = this.getUint32(); + header.mod_num = this.getUint32(); + header.appinfo_offset = this.getUint32(); + header.sortinfo_offset = this.getUint32(); + header.type = this.getStr(4); + header.creator = this.getStr(4); + header.uid = this.getUint32(); + header.next_rec = this.getUint32(); + header.record_num = this.getUint16(); + return header; + } + + load_reclist() { + var reclist = []; + for (var i = 0; i < this.header.record_num; i++) { + var record = {}; + record.offset = this.getUint32(); + // TODO(zz) change + record.attr = this.getUint32(); + reclist.push(record); + } + return reclist; + } + load_record0() { + this.palm_header = this.load_record0_header(); + this.mobi_header = this.load_mobi_header(); + } + + load_record0_header() { + var p_header = {}; + var first_record = this.reclist[0]; + this.setoffset(first_record.offset); + + p_header.compression = this.getUint16(); + this.skip(2); + p_header.text_length = this.getUint32(); + p_header.record_count = this.getUint16(); + p_header.record_size = this.getUint16(); + p_header.encryption_type = this.getUint16(); + this.skip(2); + + return p_header; + } + + load_mobi_header() { + var mobi_header = {}; + + var start_offset = this.offset; + + mobi_header.identifier = this.getUint32(); + mobi_header.header_length = this.getUint32(); + mobi_header.mobi_type = this.getUint32(); + mobi_header.text_encoding = this.getUint32(); + mobi_header.uid = this.getUint32(); + mobi_header.generator_version = this.getUint32(); + + this.skip(40); + + mobi_header.first_nonbook_index = this.getUint32(); + mobi_header.full_name_offset = this.getUint32(); + mobi_header.full_name_length = this.getUint32(); + + mobi_header.language = this.getUint32(); + mobi_header.input_language = this.getUint32(); + mobi_header.output_language = this.getUint32(); + mobi_header.min_version = this.getUint32(); + mobi_header.first_image_idx = this.getUint32(); + + mobi_header.huff_rec_index = this.getUint32(); + mobi_header.huff_rec_count = this.getUint32(); + mobi_header.datp_rec_index = this.getUint32(); + mobi_header.datp_rec_count = this.getUint32(); + + mobi_header.exth_flags = this.getUint32(); + + this.skip(36); + + mobi_header.drm_offset = this.getUint32(); + mobi_header.drm_count = this.getUint32(); + mobi_header.drm_size = this.getUint32(); + mobi_header.drm_flags = this.getUint32(); + + this.skip(8); + + // TODO (zz) fdst_index + this.skip(4); + + this.skip(46); + + mobi_header.extra_flags = this.getUint16(); + + this.setoffset(start_offset + mobi_header.header_length); + + return mobi_header; + } + load_exth_header() { + // TODO + return {}; + } + extractContent(s) { + var span = document.createElement("span"); + span.innerHTML = s; + return span.textContent || span.innerText; + } + render(isElectron = false) { + return new Promise((resolve, reject) => { + this.load(); + var content = this.read_text(); + var bookDoc = domParser.parseFromString(content, "text/html") + .documentElement; + let lines = Array.from( + bookDoc.querySelectorAll("p,b,font,h3,h2,h1") + ); + let parseContent = []; + for (let i = 0, len = lines.length; i < len - 1; i++) { + lines[i].innerText && + lines[i].innerText !== parseContent[parseContent.length - 1] && + parseContent.push(lines[i].innerText); + let imgDoms = lines[i].getElementsByTagName("img"); + if (imgDoms.length > 0) { + for (let i = 0; i < imgDoms.length; i++) { + parseContent.push("#image"); + } + } + } + const handleImage = async () => { + var imgDoms = bookDoc.getElementsByTagName("img"); + parseContent.push("~image"); + for (let i = 0; i < imgDoms.length; i++) { + const src = await this.render_image(imgDoms, i); + parseContent.push( + src + " " + imgDoms[i].width + " " + imgDoms[i].height + ); + } + if (imgDoms.length > 200 || !isElectron) { + resolve(bookDoc); + } else { + resolve(parseContent.join("\n \n")); + } + }; + handleImage(); + }); + } + render_image = (imgDoms, i) => { + return new Promise((resolve, reject) => { + var imgDom = imgDoms[i]; + var idx = +imgDom.getAttribute("recindex"); + var blob = this.read_image(idx - 1); + var imgReader = new FileReader(); + imgReader.onload = (e) => { + imgDom.src = e.target?.result; + resolve(e.target?.result); + }; + imgReader.onerror = function (err) { + reject(err); + }; + imgReader.readAsDataURL(blob); + }); + }; +} + +export default MobiFile; diff --git a/assets/fonts.css b/assets/fonts.css new file mode 100644 index 0000000..1d434b7 --- /dev/null +++ b/assets/fonts.css @@ -0,0 +1,296 @@ +@font-face { + font-family: 'Material Symbols Rounded'; + font-style: normal; + font-weight: 400; + src: url(/fonts/MaterialSymbolsRounded.woff2) format('woff2'); +} + +.material-symbols { + font-family: 'Material Symbols Rounded'; + font-weight: normal; + font-style: normal; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -webkit-font-smoothing: antialiased; + vertical-align: top; +} + +.material-symbols.fill { + font-variation-settings: + 'FILL' 1 +} + +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); + unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); + unicode-range: U+1F00-1FFF; +} + +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); + unicode-range: U+0370-03FF; +} + +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; +} + +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 300; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Light.ttf) format('ttf'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); + unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); + unicode-range: U+1F00-1FFF; +} + +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); + unicode-range: U+0370-03FF; +} + +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; +} + +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-Regular.ttf) format('ttf'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* cyrillic-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); + unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +/* cyrillic */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* greek-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); + unicode-range: U+1F00-1FFF; +} + +/* greek */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); + unicode-range: U+0370-03FF; +} + +/* vietnamese */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); + unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1, U+01AF-01B0, U+1EA0-1EF9, U+20AB; +} + +/* latin-ext */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'Source Sans Pro'; + font-style: normal; + font-weight: 600; + font-display: swap; + src: url(/fonts/Source_Sans_Pro/SourceSansPro-SemiBold.ttf) format('ttf'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} + +/* cyrillic-ext */ +@font-face { + font-family: 'Ubuntu Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); + unicode-range: U+0460-052F, U+1C80-1C88, U+20B4, U+2DE0-2DFF, U+A640-A69F, U+FE2E-FE2F; +} + +/* cyrillic */ +@font-face { + font-family: 'Ubuntu Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); + unicode-range: U+0400-045F, U+0490-0491, U+04B0-04B1, U+2116; +} + +/* greek-ext */ +@font-face { + font-family: 'Ubuntu Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); + unicode-range: U+1F00-1FFF; +} + +/* greek */ +@font-face { + font-family: 'Ubuntu Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); + unicode-range: U+0370-03FF; +} + +/* latin-ext */ +@font-face { + font-family: 'Ubuntu Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); + unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF, U+2113, U+2C60-2C7F, U+A720-A7FF; +} + +/* latin */ +@font-face { + font-family: 'Ubuntu Mono'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url(/fonts/Ubuntu_Mono/UbuntuMono-Regular.ttf) format('ttf'); + unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; +} diff --git a/assets/tailwind.css b/assets/tailwind.css new file mode 100644 index 0000000..2ecc8df --- /dev/null +++ b/assets/tailwind.css @@ -0,0 +1,66 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +@layer base { + :root { + color: white; + --color-bg: 56 56 56; + --color-bg-hover: 102 104 107; + --color-fg: 230 237 243; + --color-fg-muted: 142 147 153; + --color-primary: 35 35 35; + --color-secondary: 47 48 48; + --color-border: 75 85 89; + --color-bg-toggle: 56 56 56; + --color-bg-toggle-selected: 81 82 84; + --color-track-cursor: 229 231 235; + --color-track: 107 114 128; + --color-track-buffered: 75 85 99; + --gradient-item-page: linear-gradient(169deg, rgba(0, 0, 0, 0.4) 0%, rgba(55, 56, 56, 1) 80%); + --gradient-audio-player: linear-gradient(180deg, rgba(0, 0, 0, 0) 0%, rgba(38, 38, 38, 1) 80%); + --gradient-minimized-audio-player: linear-gradient(145deg, rgba(38, 38, 38, 0.5) 0%, rgba(38, 38, 38, 0.9) 20%, rgb(38, 38, 38) 60%); + } + + html[data-theme='black'] { + color: white; + --color-bg: 0 0 0; + --color-bg-hover: 0 0 0; + --color-fg: 230 237 243; + --color-fg-muted: 120 126 132; + --color-primary: 0 0 0; + --color-secondary: 0 0 0; + --color-border: 55 62 65; + --color-bg-toggle: 0 0 0; + --color-bg-toggle-selected: 35 35 35; + --color-track-cursor: 229 231 235; + --color-track: 107 114 128; + --color-track-buffered: 75 85 99; + --gradient-item-page: rgb(0, 0, 0); + --gradient-audio-player: rgb(0, 0, 0); + --gradient-minimized-audio-player: rgb(0, 0, 0); + } + + html[data-theme='light'] { + color: black; + --color-bg: 255 255 255; + --color-bg-hover: 208 210 212; + --color-fg: 37 37 37; + --color-fg-muted: 101 109 118; + --color-primary: 222 222 222; + --color-secondary: 246 248 250; + --color-border: 189 191 191; + --color-bg-toggle: 222 222 222; + --color-bg-toggle-selected: 255 255 255; + --color-track-cursor: 101 109 118; + --color-track: 189 191 191; + --color-track-buffered: 129 131 131; + --gradient-item-page: linear-gradient(169deg, rgba(255, 255, 255, 0.4) 0%, rgba(255, 255, 255, 1) 80%); + --gradient-audio-player: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(255, 255, 255, 1) 80%); + --gradient-minimized-audio-player: linear-gradient(145deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0.9) 20%, rgb(255, 255, 255) 60%); + } + + body { + @apply bg-primary + } +} \ No newline at end of file diff --git a/assets/transitions.css b/assets/transitions.css new file mode 100644 index 0000000..63e9cfc --- /dev/null +++ b/assets/transitions.css @@ -0,0 +1,51 @@ +.slide-enter-active { + -moz-transition-duration: 0.1s; + -webkit-transition-duration: 0.1s; + -o-transition-duration: 0.1s; + transition-duration: 0.1s; + -moz-transition-timing-function: ease-in; + -webkit-transition-timing-function: ease-in; + -o-transition-timing-function: ease-in; + transition-timing-function: ease-in; +} + +.slide-leave-active { + -moz-transition-duration: 0.2s; + -webkit-transition-duration: 0.2s; + -o-transition-duration: 0.2s; + transition-duration: 0.2s; + -moz-transition-timing-function: cubic-bezier(0, 1, 0.5, 1); + -webkit-transition-timing-function: cubic-bezier(0, 1, 0.5, 1); + -o-transition-timing-function: cubic-bezier(0, 1, 0.5, 1); + transition-timing-function: cubic-bezier(0, 1, 0.5, 1); +} + +.slide-enter-to, +.slide-leave { + max-height: 600px; + overflow: hidden; +} + +.slide-enter, +.slide-leave-to { + overflow: hidden; + max-height: 0; +} + +.menu-enter, +.menu-leave-active { + transform: translateY(-15px); +} + +.menu-enter-active { + transition: all 0.2s; +} + +.menu-leave-active { + transition: all 0.1s; +} + +.menu-enter, +.menu-leave-active { + opacity: 0; +} \ No newline at end of file diff --git a/capacitor.config.json b/capacitor.config.json new file mode 100644 index 0000000..8947e8d --- /dev/null +++ b/capacitor.config.json @@ -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" + } +} diff --git a/components/app/Appbar.vue b/components/app/Appbar.vue new file mode 100644 index 0000000..903fa33 --- /dev/null +++ b/components/app/Appbar.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/components/app/AudioPlayer.vue b/components/app/AudioPlayer.vue new file mode 100644 index 0000000..c9d2f63 --- /dev/null +++ b/components/app/AudioPlayer.vue @@ -0,0 +1,1239 @@ + + + + + diff --git a/components/app/AudioPlayerContainer.vue b/components/app/AudioPlayerContainer.vue new file mode 100644 index 0000000..f9f8404 --- /dev/null +++ b/components/app/AudioPlayerContainer.vue @@ -0,0 +1,474 @@ + + + diff --git a/components/app/SideDrawer.vue b/components/app/SideDrawer.vue new file mode 100644 index 0000000..dd2acba --- /dev/null +++ b/components/app/SideDrawer.vue @@ -0,0 +1,221 @@ + + + diff --git a/components/bookshelf/LazyBookshelf.vue b/components/bookshelf/LazyBookshelf.vue new file mode 100644 index 0000000..df6782d --- /dev/null +++ b/components/bookshelf/LazyBookshelf.vue @@ -0,0 +1,562 @@ + + + diff --git a/components/bookshelf/Shelf.vue b/components/bookshelf/Shelf.vue new file mode 100644 index 0000000..2095a67 --- /dev/null +++ b/components/bookshelf/Shelf.vue @@ -0,0 +1,81 @@ + + + diff --git a/components/cards/AuthorCard.vue b/components/cards/AuthorCard.vue new file mode 100644 index 0000000..fbc2365 --- /dev/null +++ b/components/cards/AuthorCard.vue @@ -0,0 +1,61 @@ + + + \ No newline at end of file diff --git a/components/cards/AuthorSearchCard.vue b/components/cards/AuthorSearchCard.vue new file mode 100644 index 0000000..6afbab7 --- /dev/null +++ b/components/cards/AuthorSearchCard.vue @@ -0,0 +1,40 @@ + + + + + \ No newline at end of file diff --git a/components/cards/EpisodeSearchCard.vue b/components/cards/EpisodeSearchCard.vue new file mode 100644 index 0000000..1968d57 --- /dev/null +++ b/components/cards/EpisodeSearchCard.vue @@ -0,0 +1,69 @@ + + + + + diff --git a/components/cards/ItemSearchCard.vue b/components/cards/ItemSearchCard.vue new file mode 100644 index 0000000..190a7b4 --- /dev/null +++ b/components/cards/ItemSearchCard.vue @@ -0,0 +1,67 @@ + + + + + diff --git a/components/cards/LazyBookCard.vue b/components/cards/LazyBookCard.vue new file mode 100644 index 0000000..312e052 --- /dev/null +++ b/components/cards/LazyBookCard.vue @@ -0,0 +1,571 @@ + + + diff --git a/components/cards/LazyCollectionCard.vue b/components/cards/LazyCollectionCard.vue new file mode 100644 index 0000000..7126ac8 --- /dev/null +++ b/components/cards/LazyCollectionCard.vue @@ -0,0 +1,80 @@ + + + \ No newline at end of file diff --git a/components/cards/LazyListBookCard.vue b/components/cards/LazyListBookCard.vue new file mode 100644 index 0000000..f58f507 --- /dev/null +++ b/components/cards/LazyListBookCard.vue @@ -0,0 +1,416 @@ + + + + + diff --git a/components/cards/LazyPlaylistCard.vue b/components/cards/LazyPlaylistCard.vue new file mode 100644 index 0000000..1518e6d --- /dev/null +++ b/components/cards/LazyPlaylistCard.vue @@ -0,0 +1,86 @@ + + + \ No newline at end of file diff --git a/components/cards/LazySeriesCard.vue b/components/cards/LazySeriesCard.vue new file mode 100644 index 0000000..cff4441 --- /dev/null +++ b/components/cards/LazySeriesCard.vue @@ -0,0 +1,123 @@ + + + diff --git a/components/cards/NarratorSearchCard.vue b/components/cards/NarratorSearchCard.vue new file mode 100644 index 0000000..29db17a --- /dev/null +++ b/components/cards/NarratorSearchCard.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/components/cards/SeriesSearchCard.vue b/components/cards/SeriesSearchCard.vue new file mode 100644 index 0000000..754de2b --- /dev/null +++ b/components/cards/SeriesSearchCard.vue @@ -0,0 +1,45 @@ + + + + + \ No newline at end of file diff --git a/components/cards/TagSearchCard.vue b/components/cards/TagSearchCard.vue new file mode 100644 index 0000000..10cdfe3 --- /dev/null +++ b/components/cards/TagSearchCard.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/components/connection/ServerConnectForm.vue b/components/connection/ServerConnectForm.vue new file mode 100644 index 0000000..2410562 --- /dev/null +++ b/components/connection/ServerConnectForm.vue @@ -0,0 +1,980 @@ + + + diff --git a/components/covers/AuthorImage.vue b/components/covers/AuthorImage.vue new file mode 100644 index 0000000..d1b0f05 --- /dev/null +++ b/components/covers/AuthorImage.vue @@ -0,0 +1,91 @@ + + + \ No newline at end of file diff --git a/components/covers/BookCover.vue b/components/covers/BookCover.vue new file mode 100644 index 0000000..9efeaa4 --- /dev/null +++ b/components/covers/BookCover.vue @@ -0,0 +1,182 @@ + + + + diff --git a/components/covers/CollectionCover.vue b/components/covers/CollectionCover.vue new file mode 100644 index 0000000..08a1b3d --- /dev/null +++ b/components/covers/CollectionCover.vue @@ -0,0 +1,61 @@ + + + \ No newline at end of file diff --git a/components/covers/GroupCover.vue b/components/covers/GroupCover.vue new file mode 100644 index 0000000..3e25c6d --- /dev/null +++ b/components/covers/GroupCover.vue @@ -0,0 +1,204 @@ + + + diff --git a/components/covers/PlaylistCover.vue b/components/covers/PlaylistCover.vue new file mode 100644 index 0000000..e19bfc3 --- /dev/null +++ b/components/covers/PlaylistCover.vue @@ -0,0 +1,51 @@ + + + \ No newline at end of file diff --git a/components/covers/PreviewCover.vue b/components/covers/PreviewCover.vue new file mode 100644 index 0000000..d49d271 --- /dev/null +++ b/components/covers/PreviewCover.vue @@ -0,0 +1,103 @@ + + + diff --git a/components/forms/NewPodcastForm.vue b/components/forms/NewPodcastForm.vue new file mode 100644 index 0000000..b070ff3 --- /dev/null +++ b/components/forms/NewPodcastForm.vue @@ -0,0 +1,182 @@ + + + \ No newline at end of file diff --git a/components/home/BookshelfNavBar.vue b/components/home/BookshelfNavBar.vue new file mode 100644 index 0000000..1bfb4b8 --- /dev/null +++ b/components/home/BookshelfNavBar.vue @@ -0,0 +1,153 @@ + + + + + diff --git a/components/home/BookshelfToolbar.vue b/components/home/BookshelfToolbar.vue new file mode 100644 index 0000000..5e48b3f --- /dev/null +++ b/components/home/BookshelfToolbar.vue @@ -0,0 +1,170 @@ + + + + + diff --git a/components/modals/AutoSleepTimerRewindLengthModal.vue b/components/modals/AutoSleepTimerRewindLengthModal.vue new file mode 100644 index 0000000..b32ecb4 --- /dev/null +++ b/components/modals/AutoSleepTimerRewindLengthModal.vue @@ -0,0 +1,99 @@ + + + diff --git a/components/modals/BookmarksModal.vue b/components/modals/BookmarksModal.vue new file mode 100644 index 0000000..edd9335 --- /dev/null +++ b/components/modals/BookmarksModal.vue @@ -0,0 +1,182 @@ + + + diff --git a/components/modals/ChaptersModal.vue b/components/modals/ChaptersModal.vue new file mode 100644 index 0000000..0cb4dab --- /dev/null +++ b/components/modals/ChaptersModal.vue @@ -0,0 +1,125 @@ + + + diff --git a/components/modals/CustomHeadersModal.vue b/components/modals/CustomHeadersModal.vue new file mode 100644 index 0000000..913eabc --- /dev/null +++ b/components/modals/CustomHeadersModal.vue @@ -0,0 +1,96 @@ + + + diff --git a/components/modals/Dialog.vue b/components/modals/Dialog.vue new file mode 100644 index 0000000..6543f0e --- /dev/null +++ b/components/modals/Dialog.vue @@ -0,0 +1,96 @@ + + + diff --git a/components/modals/FilterModal.vue b/components/modals/FilterModal.vue new file mode 100644 index 0000000..296db32 --- /dev/null +++ b/components/modals/FilterModal.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/components/modals/FullscreenCover.vue b/components/modals/FullscreenCover.vue new file mode 100644 index 0000000..a903ce4 --- /dev/null +++ b/components/modals/FullscreenCover.vue @@ -0,0 +1,74 @@ + + + + + \ No newline at end of file diff --git a/components/modals/FullscreenModal.vue b/components/modals/FullscreenModal.vue new file mode 100644 index 0000000..4eacc41 --- /dev/null +++ b/components/modals/FullscreenModal.vue @@ -0,0 +1,77 @@ + + + diff --git a/components/modals/ItemDetailsModal.vue b/components/modals/ItemDetailsModal.vue new file mode 100644 index 0000000..0f62e7a --- /dev/null +++ b/components/modals/ItemDetailsModal.vue @@ -0,0 +1,58 @@ + + + diff --git a/components/modals/ItemMoreMenuModal.vue b/components/modals/ItemMoreMenuModal.vue new file mode 100644 index 0000000..ab1a468 --- /dev/null +++ b/components/modals/ItemMoreMenuModal.vue @@ -0,0 +1,511 @@ + + + \ No newline at end of file diff --git a/components/modals/LibrariesModal.vue b/components/modals/LibrariesModal.vue new file mode 100644 index 0000000..936f18f --- /dev/null +++ b/components/modals/LibrariesModal.vue @@ -0,0 +1,62 @@ + + + diff --git a/components/modals/Modal.vue b/components/modals/Modal.vue new file mode 100644 index 0000000..bb2a56d --- /dev/null +++ b/components/modals/Modal.vue @@ -0,0 +1,113 @@ + + + diff --git a/components/modals/OrderModal.vue b/components/modals/OrderModal.vue new file mode 100644 index 0000000..495c5db --- /dev/null +++ b/components/modals/OrderModal.vue @@ -0,0 +1,191 @@ + + + diff --git a/components/modals/PlaybackSpeedModal.vue b/components/modals/PlaybackSpeedModal.vue new file mode 100644 index 0000000..f61903e --- /dev/null +++ b/components/modals/PlaybackSpeedModal.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/components/modals/PodcastEpisodesFeedModal.vue b/components/modals/PodcastEpisodesFeedModal.vue new file mode 100644 index 0000000..cca9a08 --- /dev/null +++ b/components/modals/PodcastEpisodesFeedModal.vue @@ -0,0 +1,137 @@ + + + + + diff --git a/components/modals/SelectLocalFolderModal.vue b/components/modals/SelectLocalFolderModal.vue new file mode 100644 index 0000000..87613a3 --- /dev/null +++ b/components/modals/SelectLocalFolderModal.vue @@ -0,0 +1,83 @@ + + + diff --git a/components/modals/SleepTimerLengthModal.vue b/components/modals/SleepTimerLengthModal.vue new file mode 100644 index 0000000..1e399ea --- /dev/null +++ b/components/modals/SleepTimerLengthModal.vue @@ -0,0 +1,100 @@ + + + diff --git a/components/modals/SleepTimerModal.vue b/components/modals/SleepTimerModal.vue new file mode 100644 index 0000000..a648f2c --- /dev/null +++ b/components/modals/SleepTimerModal.vue @@ -0,0 +1,140 @@ + + + diff --git a/components/modals/bookmarks/BookmarkItem.vue b/components/modals/bookmarks/BookmarkItem.vue new file mode 100644 index 0000000..19421a3 --- /dev/null +++ b/components/modals/bookmarks/BookmarkItem.vue @@ -0,0 +1,45 @@ + + + diff --git a/components/modals/downloads/DownloadItem.vue b/components/modals/downloads/DownloadItem.vue new file mode 100644 index 0000000..1596653 --- /dev/null +++ b/components/modals/downloads/DownloadItem.vue @@ -0,0 +1,52 @@ + + + diff --git a/components/modals/playlists/AddCreateModal.vue b/components/modals/playlists/AddCreateModal.vue new file mode 100644 index 0000000..82d8f63 --- /dev/null +++ b/components/modals/playlists/AddCreateModal.vue @@ -0,0 +1,231 @@ + + + diff --git a/components/modals/playlists/PlaylistRow.vue b/components/modals/playlists/PlaylistRow.vue new file mode 100644 index 0000000..0c1cd07 --- /dev/null +++ b/components/modals/playlists/PlaylistRow.vue @@ -0,0 +1,45 @@ + + + \ No newline at end of file diff --git a/components/modals/rssfeeds/RssFeedMetadataBuilder.vue b/components/modals/rssfeeds/RssFeedMetadataBuilder.vue new file mode 100644 index 0000000..6c94cd9 --- /dev/null +++ b/components/modals/rssfeeds/RssFeedMetadataBuilder.vue @@ -0,0 +1,90 @@ + + + diff --git a/components/modals/rssfeeds/RssFeedModal.vue b/components/modals/rssfeeds/RssFeedModal.vue new file mode 100644 index 0000000..9172076 --- /dev/null +++ b/components/modals/rssfeeds/RssFeedModal.vue @@ -0,0 +1,191 @@ + + + diff --git a/components/readers/ComicReader.vue b/components/readers/ComicReader.vue new file mode 100644 index 0000000..f62ea87 --- /dev/null +++ b/components/readers/ComicReader.vue @@ -0,0 +1,387 @@ + + + + + diff --git a/components/readers/EpubReader.vue b/components/readers/EpubReader.vue new file mode 100644 index 0000000..0618a63 --- /dev/null +++ b/components/readers/EpubReader.vue @@ -0,0 +1,459 @@ + + + + + diff --git a/components/readers/MobiReader.vue b/components/readers/MobiReader.vue new file mode 100644 index 0000000..53de8ec --- /dev/null +++ b/components/readers/MobiReader.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/components/readers/PdfReader.vue b/components/readers/PdfReader.vue new file mode 100644 index 0000000..5cefaa7 --- /dev/null +++ b/components/readers/PdfReader.vue @@ -0,0 +1,261 @@ + + + diff --git a/components/readers/Reader.vue b/components/readers/Reader.vue new file mode 100644 index 0000000..0fcb295 --- /dev/null +++ b/components/readers/Reader.vue @@ -0,0 +1,549 @@ + + + diff --git a/components/stats/DailyListeningChart.vue b/components/stats/DailyListeningChart.vue new file mode 100644 index 0000000..e770735 --- /dev/null +++ b/components/stats/DailyListeningChart.vue @@ -0,0 +1,225 @@ + + + diff --git a/components/stats/YearInReview.vue b/components/stats/YearInReview.vue new file mode 100644 index 0000000..d357464 --- /dev/null +++ b/components/stats/YearInReview.vue @@ -0,0 +1,283 @@ + + + \ No newline at end of file diff --git a/components/stats/YearInReviewBanner.vue b/components/stats/YearInReviewBanner.vue new file mode 100644 index 0000000..7e43fe8 --- /dev/null +++ b/components/stats/YearInReviewBanner.vue @@ -0,0 +1,134 @@ + + + diff --git a/components/stats/YearInReviewServer.vue b/components/stats/YearInReviewServer.vue new file mode 100644 index 0000000..d655efb --- /dev/null +++ b/components/stats/YearInReviewServer.vue @@ -0,0 +1,262 @@ + + + \ No newline at end of file diff --git a/components/stats/YearInReviewShort.vue b/components/stats/YearInReviewShort.vue new file mode 100644 index 0000000..fb84264 --- /dev/null +++ b/components/stats/YearInReviewShort.vue @@ -0,0 +1,192 @@ + + + \ No newline at end of file diff --git a/components/tables/ChaptersTable.vue b/components/tables/ChaptersTable.vue new file mode 100644 index 0000000..8c05697 --- /dev/null +++ b/components/tables/ChaptersTable.vue @@ -0,0 +1,76 @@ + + + diff --git a/components/tables/TracksTable.vue b/components/tables/TracksTable.vue new file mode 100644 index 0000000..bf189ed --- /dev/null +++ b/components/tables/TracksTable.vue @@ -0,0 +1,56 @@ + + + diff --git a/components/tables/collection/BookTableRow.vue b/components/tables/collection/BookTableRow.vue new file mode 100644 index 0000000..9828983 --- /dev/null +++ b/components/tables/collection/BookTableRow.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/components/tables/collection/CollectionBooksTable.vue b/components/tables/collection/CollectionBooksTable.vue new file mode 100644 index 0000000..b7abb7d --- /dev/null +++ b/components/tables/collection/CollectionBooksTable.vue @@ -0,0 +1,82 @@ + + + + + \ No newline at end of file diff --git a/components/tables/ebook/EbookFilesTable.vue b/components/tables/ebook/EbookFilesTable.vue new file mode 100644 index 0000000..39ab9b6 --- /dev/null +++ b/components/tables/ebook/EbookFilesTable.vue @@ -0,0 +1,99 @@ + + + diff --git a/components/tables/ebook/EbookFilesTableRow.vue b/components/tables/ebook/EbookFilesTableRow.vue new file mode 100644 index 0000000..263dcaf --- /dev/null +++ b/components/tables/ebook/EbookFilesTableRow.vue @@ -0,0 +1,60 @@ + + + diff --git a/components/tables/playlist/ItemTableRow.vue b/components/tables/playlist/ItemTableRow.vue new file mode 100644 index 0000000..953a003 --- /dev/null +++ b/components/tables/playlist/ItemTableRow.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/components/tables/playlist/PlaylistItemsTable.vue b/components/tables/playlist/PlaylistItemsTable.vue new file mode 100644 index 0000000..72e77af --- /dev/null +++ b/components/tables/playlist/PlaylistItemsTable.vue @@ -0,0 +1,51 @@ + + + diff --git a/components/tables/podcast/EpisodeRow.vue b/components/tables/podcast/EpisodeRow.vue new file mode 100644 index 0000000..fde0d8c --- /dev/null +++ b/components/tables/podcast/EpisodeRow.vue @@ -0,0 +1,328 @@ + + + diff --git a/components/tables/podcast/EpisodesTable.vue b/components/tables/podcast/EpisodesTable.vue new file mode 100644 index 0000000..5603e86 --- /dev/null +++ b/components/tables/podcast/EpisodesTable.vue @@ -0,0 +1,348 @@ + + + diff --git a/components/tables/podcast/LatestEpisodeRow.vue b/components/tables/podcast/LatestEpisodeRow.vue new file mode 100644 index 0000000..b5d5591 --- /dev/null +++ b/components/tables/podcast/LatestEpisodeRow.vue @@ -0,0 +1,335 @@ + + + diff --git a/components/ui/Btn.vue b/components/ui/Btn.vue new file mode 100644 index 0000000..630ed54 --- /dev/null +++ b/components/ui/Btn.vue @@ -0,0 +1,96 @@ + + + + + \ No newline at end of file diff --git a/components/ui/Checkbox.vue b/components/ui/Checkbox.vue new file mode 100644 index 0000000..4c144dc --- /dev/null +++ b/components/ui/Checkbox.vue @@ -0,0 +1,71 @@ + + + \ No newline at end of file diff --git a/components/ui/Dropdown.vue b/components/ui/Dropdown.vue new file mode 100644 index 0000000..a4c5ffa --- /dev/null +++ b/components/ui/Dropdown.vue @@ -0,0 +1,89 @@ + + + diff --git a/components/ui/DropdownMenu.vue b/components/ui/DropdownMenu.vue new file mode 100644 index 0000000..096f44f --- /dev/null +++ b/components/ui/DropdownMenu.vue @@ -0,0 +1,105 @@ + + + diff --git a/components/ui/IconBtn.vue b/components/ui/IconBtn.vue new file mode 100644 index 0000000..e9ca445 --- /dev/null +++ b/components/ui/IconBtn.vue @@ -0,0 +1,84 @@ + + + + + diff --git a/components/ui/LibraryIcon.vue b/components/ui/LibraryIcon.vue new file mode 100644 index 0000000..a615e84 --- /dev/null +++ b/components/ui/LibraryIcon.vue @@ -0,0 +1,37 @@ + + + \ No newline at end of file diff --git a/components/ui/LoadingIndicator.vue b/components/ui/LoadingIndicator.vue new file mode 100644 index 0000000..0879d33 --- /dev/null +++ b/components/ui/LoadingIndicator.vue @@ -0,0 +1,70 @@ + + + + + \ No newline at end of file diff --git a/components/ui/Menu.vue b/components/ui/Menu.vue new file mode 100644 index 0000000..9d9bd6a --- /dev/null +++ b/components/ui/Menu.vue @@ -0,0 +1,61 @@ + + + diff --git a/components/ui/MultiSelect.vue b/components/ui/MultiSelect.vue new file mode 100644 index 0000000..03222f6 --- /dev/null +++ b/components/ui/MultiSelect.vue @@ -0,0 +1,261 @@ + + + + + diff --git a/components/ui/RangeInput.vue b/components/ui/RangeInput.vue new file mode 100644 index 0000000..40e6c9c --- /dev/null +++ b/components/ui/RangeInput.vue @@ -0,0 +1,90 @@ + + + + + \ No newline at end of file diff --git a/components/ui/ReadIconBtn.vue b/components/ui/ReadIconBtn.vue new file mode 100644 index 0000000..2ffdb6e --- /dev/null +++ b/components/ui/ReadIconBtn.vue @@ -0,0 +1,57 @@ + + + + + \ No newline at end of file diff --git a/components/ui/TextInput.vue b/components/ui/TextInput.vue new file mode 100644 index 0000000..600f77e --- /dev/null +++ b/components/ui/TextInput.vue @@ -0,0 +1,98 @@ + + + + + diff --git a/components/ui/TextInputWithLabel.vue b/components/ui/TextInputWithLabel.vue new file mode 100644 index 0000000..10deb8f --- /dev/null +++ b/components/ui/TextInputWithLabel.vue @@ -0,0 +1,40 @@ + + + \ No newline at end of file diff --git a/components/ui/TextareaInput.vue b/components/ui/TextareaInput.vue new file mode 100644 index 0000000..08acc58 --- /dev/null +++ b/components/ui/TextareaInput.vue @@ -0,0 +1,53 @@ +