diff --git a/.clang-format b/.clang-format new file mode 100644 index 000000000..6165b7078 --- /dev/null +++ b/.clang-format @@ -0,0 +1,4 @@ +# Defines the Chromium style for automatic reformatting. +# http://clang.llvm.org/docs/ClangFormatStyleOptions.html +BasedOnStyle: Chromium +Standard: c++17 diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..ac7450edd --- /dev/null +++ b/.editorconfig @@ -0,0 +1,13 @@ +[{*.kt,*.kts}] +max_line_length = 120 +insert_final_newline = true +ij_kotlin_code_style_defaults = KOTLIN_OFFICIAL +ij_kotlin_continuation_indent_size = 4 +ij_kotlin_allow_trailing_comma_on_call_site = true +ij_kotlin_allow_trailing_comma = true +ij_kotlin_name_count_to_use_star_import = 999 +ij_kotlin_name_count_to_use_star_import_for_members = 999 + +[*.swift] +indent_style = space +indent_size = 2 \ No newline at end of file diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..e6edbca3d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,3 @@ +open_collective: blue-fire +github: bluefireteam +patreon: bluefireoss diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md deleted file mode 100644 index d01143f12..000000000 --- a/.github/ISSUE_TEMPLATE/bug-report.md +++ /dev/null @@ -1,36 +0,0 @@ ---- -name: Bug Report -about: If you found a bug, please use this template to report it. -title: '' -labels: bug -assignees: '' - ---- - -["one liner" - concisely describe your bug with one very brief paragraph, preferably between 1-3 lines, in clear, correct and acceptable English] - -**Full Description** -Describe in what the bug is, exactly how, what and when it happens. In particular specifying what you thought should happen and what did happen, and how they differ, in detail. Unless the bug is abundantly clear by the "one liner" (rarely is), this is mandatory. Try to be concise but it's better to write too much than too little. - -**Code to Reproduce** -A [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example), i.e., code that allows us to replicate the bug. This is **mandatory**. It can be one line, if it already is enough to reproduce (rarely is), or a bigger example with all your relevant code. You **must** use code blocks to put your code in. Don't send your whole project repo, please read the article linked and extract a **minimal** example, but feel free to link a newly created sample repo reproducing the issue instead of pasting code if necessary (rarely is). If you do link a repo, you must put in at least a little bit of the relevant code and where to find it in the repo. - -**Log Errors** -If your problem involves log errors or messages, please put them here, in full, but feel free to highlight the parts that are relevant. You **must** use code blocks (or gists) to paste in log lines. An absolutely outstanding example would be to add a code block with 2-3 relevant log lines, followed by a link to a gist with the whole log from which the relevant lines were extracted. - -**Files/URLs/Sources** -If your issue or bug involves specific files, you must provide them. If they are URLs they should already be somewhere in the code provided but please replicate those here. If they are private URLs or actual files, please upload them somewhere accessible and add them here. If your issue is with a stream, for example, provide the stream link or if the stream is private, create a similar stream that also causes the problem and is public, and add that. If you don't know if your issue involves specific resources, please download a few of the [mp3 sample files](https://github.com/luanpotter/audioplayers/tree/master/example/assets) present in the `example` app and test with those instead of whatever you are doing currently. If the issue persists, it does not involve specific files. Otherwise, this section is **mandatory**. - -**Screenshots** -If applicable, add screenshots or video recordings to help explain your problem. This is totally optional. - -**Platforms** -List in detail here **all** the platforms that you have tested, indicating which platforms the error occurs on. Please try to test on as many different platforms as possible to make our lives easier (this is optional). You must add at least one platform in which the problem occurs (evidently). For each platform listed, you must specify: - -* OS: web, android, ios, macos, etc. if web, which browser was used. -* OS version: the android version, ios/macos versions or browser version used. -* Device: whether it was a physical device or emulator/simulator. If running on a physical device, what brand/model/kind of device was used. If running on an emulator, what host desktop was used. For web/desktop, what desktop OS and version was used. -* flutter version: what exact flutter version you used. If you think it might be a configuration/setup issue, feel free to add a link to a gist with the output of `flutter doctor -v` (this is optional). -* audioplayers version: what version on pub you used, or if you built it yourself what branch and was the latest commit hash -* release or not release: specify if you ran the app in release, profile or debug modes. Also specify here any additional flags or conditions that might be relevant (for example, if you ran flutter web with the skia use canvas flag set to true, please mention that here). -* does the error occur and does it have any peculiarities: of course if you only tested in one platform, clearly the error occurred there. But if you happened to test on several platforms, please include all of your tests, even the ones that succeeded here, as that info is super valuable. Add details of what was the result in each platform to the extent to which they differed. diff --git a/.github/ISSUE_TEMPLATE/bug-report.yml b/.github/ISSUE_TEMPLATE/bug-report.yml new file mode 100644 index 000000000..cfde6aff4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report.yml @@ -0,0 +1,186 @@ +name: Bug Report +description: File a bug report +labels: ["bug", "triage"] +body: + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I read the [troubleshooting guide](https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md) before raising this issue + required: true + - label: I made sure that the issue I am raising doesn't already exist + required: true + - type: textarea + id: current-bug + attributes: + label: Current bug behaviour + placeholder: Tell us what you see! + validations: + required: true + - type: textarea + id: expected-bug + attributes: + label: Expected behaviour + placeholder: Tell us, what did you expect to happen? + validations: + required: true + - type: textarea + id: reproduction-steps + attributes: + label: Steps to reproduce + value: | + 1. Execute `flutter run` on the code sample + 2. ... + 3. ... + validations: + required: false + - type: textarea + id: code-sample + attributes: + label: Code sample + description: | + Provide a [minimal reproducible example](https://stackoverflow.com/help/minimal-reproducible-example), i.e., code that allows us to replicate the bug. You have the following options: + * "One liner", if it already is enough to reproduce (rarely is). + * A bigger example with all your relevant code. You **must** use code blocks to put your code in. + * Link a newly created sample repo reproducing the issue: + * Either use a newly created sample, e.g. via `flutter create my_bug` + * Or use the existing [example](https://github.com/bluefireteam/audioplayers/tree/main/packages/audioplayers/example) + value: | +
+ Code sample + + ```dart + void main() { + } + ``` + +
+ validations: + required: false + - type: dropdown + id: platforms + attributes: + label: Affected platforms + description: What platforms are you seeing the problem on? + multiple: true + options: + - Android + - iOS + - web + - Windows + - Linux + - macOS + - other + validations: + required: true + - type: textarea + id: platform-details + attributes: + label: Platform details + description: If relevant, what exact device(s) or operating system do you use? + placeholder: | + * Platform 1: iOS 16.3, Apple iPhone 12 + * Platform 2: Windows 11, Microsoft Surface Laptop 4 + * Platform 3: Android 12 (API 32), Android Emulator + * Platform 4: Chrome `109.0.5414.121`, MacBook Pro 13" 2022 M2 + validations: + required: false + - type: input + id: ap-version + attributes: + label: AudioPlayers Version + description: In which AudioPlayers version does the issue occur? + placeholder: main, 1.0.2, 3.0.1 + validations: + required: true + - type: dropdown + id: build-mode + attributes: + label: Build mode + multiple: true + options: + - debug + - profile + - release + validations: + required: false + - type: textarea + id: sources + attributes: + label: Audio Files/URLs/Sources + description: | + * Provide the files or URLs which may affect the issue. + * Test your code with some of the [sample files](https://github.com/luanpotter/audioplayers/tree/master/example/assets). If the problem persists you don't need to provide your own source, otherwise, this section is **mandatory**. + * Rewrite URLs here which are already somewhere in your sample code. + * Make private URLs or actual files accessible, by uploading them (e.g. wrap them in a `zip` file for GitHub). + placeholder: | + Drag and drop your source files here or provide a link! + + * Sample Url: https://luan.xyz/files/audio/coins.wav + * Sample Stream: https://example.com/my_stream.m3u8 + validations: + required: false + - type: textarea + id: screenshots + attributes: + label: Screenshots + description: | + If applicable, add screenshots or video recordings to help explain your problem. This is totally optional. + You can upload them directly on GitHub. Beware that video file size is limited to 10MB. + placeholder: Drag and drop your screenshots here! + validations: + required: false + - type: textarea + id: logs + attributes: + label: Logs + description: | + * Relevant logs + * Full logs + * Flutter doctor + value: | + + ``` + my relevant logs + ``` + +
+ Full Logs + + + + ``` + my full logs or a link to a gist + ``` + + Flutter doctor: + ``` + Output of: flutter doctor -v + ``` +
+ validations: + required: false + - type: textarea + id: more-info + attributes: + label: Related issues / more information + description: | + Do you have any other useful information about this bug report? + References to other issues / sites / repositories? + placeholder: | + Related: #123, #456 + validations: + required: false + - type: dropdown + id: working-pull-request + attributes: + label: Working on PR + description: Are you interested in working on a PR for this? + multiple: false + # For some reason GH forms does not allow "yes" as option. + options: + - no way + - yeah + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/feature-request.md b/.github/ISSUE_TEMPLATE/feature-request.md deleted file mode 100644 index b387d5b2d..000000000 --- a/.github/ISSUE_TEMPLATE/feature-request.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: Feature Request -about: If you have a suggestion for some change or new feature, please use this template - to report it. -title: '' -labels: feature-request -assignees: '' - ---- - -*First of all, make sure that the feature you are proposing doesn't already exist. Delete this line only once you did that.* - -["one liner" - concisely describe your feature request with one very brief paragraph, preferably between 1-3 lines, in clear, correct and acceptable English] - -**Full Description** -Describe thoroughly what the feature you desire is, why it's useful and why it fits in this package (and not a standalone package for example). Mention what your use case is (what you want to use it for) and how it should be included (what API changes that are necessary, if they are breaking changes, etc). This is mandatory. Please include how it should be implemented as well (is it a new flag to pass through to android/ios, is it just a new method using some build in functions, etc). This last part is optional but highly encouraged. It's super valuable to do some research to help us make sure your idea is doable. This section is mandatory. - -**Example Code** -Unless your feature requires no API changes whatsoever, provide a very concise code example of how it would be used in a Flutter app. This is **mandatory** if your feature requires any API changes. You **must** use code blocks to put your code in. You can link an example repo (unnecessary) but you must add a simple version on the issue. - -**Platforms** -Make sure to identify for which platforms this must be implemented. By platforms here I mean: android, ios, web, macos, etc. This section is mandatory. - -First, assess if this already exists or not on some platforms but not on others. Some stuff we currently only support on Android or only on iOS because Open Source projects are made with the help of the community and we not always have time to do everything. If, for example, you want an iOS feature to be added to android, that is totally fine but specify that here. In this case, provide in the sections above a clear explanation of how it already works on all platforms its present on (e.g. the Code section must include code for how to use it on platform X and what changes (if any, probably none) are necessary on the api level to add it to platform Y). - -Secondly, asses in which platforms this make sense to be added. Especially web/macos. There are some features that just don't make sense outside of the mobile world, for example. Not everything needs to be present on all platforms. Please think about which platforms this should be added for, ideally. That doesn't mean we will end up doing all of them to start, but just as the goal. - -This is also a perfect section to include, if your prior research already revealed it, if there are minimum OS versions that are absolutely required to add this feature. For example, if it's only possible to do it after Android 23, add that detail here. - -**Sample Files/URLs/Sources** -If your feature request involves a new type of file, a different URL, stream type, etc, please provide here any resources that would be required to test or develop your feature. For example, if you wanted to play a different kind of file, please provide such file. This section is optional (only add it if it makes sense). - -**Mockups** -If applicable, add mockup screenshots or videos to help to explain what this feature would look like. This is totally optional. It can also be screenshots/screen recordings of existing apps, built using other libraries (like native libraries), to do what you want, or of how it works on other platforms if it already exists on audioplayers on some platforms. - -**Research Resources** -During your research for your use case, you might have encountered some existing apps using this feature through other (maybe native) libraries, tutorials explaining how to do it on the native side, articles explaining why this is useful for end users, etc. Add here any links or references that you have to help to make your case for this feature. This is optional but highly encouraged if your feature is anything bigger or more complex. diff --git a/.github/ISSUE_TEMPLATE/feature-request.yml b/.github/ISSUE_TEMPLATE/feature-request.yml new file mode 100644 index 000000000..8e841b9ee --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yml @@ -0,0 +1,125 @@ +name: Feature Request +description: File a suggestion or feature request +labels: ["feature-request", "triage"] +body: + - type: checkboxes + id: checklist + attributes: + label: Checklist + options: + - label: I made sure that the issue I am raising doesn't already exist + required: true + - type: textarea + id: use-case + attributes: + label: Use case / Problem + description: | + Which problem would be solved with this feature? + Why it fits in this package (and not a standalone package for example) + placeholder: Tell us your use case! + validations: + required: true + - type: textarea + id: proposal + attributes: + label: Proposal / Solution + description: | + What do you propose as a solution? Add as much information as you can! + What API changes that are necessary? Are there any breaking changes expected? + placeholder: Tell us, how to solve the problem? + validations: + required: true + - type: textarea + id: code-example + attributes: + label: Example Code + description: | + If your feature requires an API change, provide a very concise code example of how it would be used in a Flutter app. + You can also link an example repo (if wanted) but keep it simple. Or use the [example project](https://github.com/bluefireteam/audioplayers/tree/main/packages/audioplayers/example). + value: | +
+ Example Code + + ```dart + void main() { + } + ``` + +
+ validations: + required: false + - type: dropdown + id: platforms + attributes: + label: Affected platforms + description: What platforms are you seeing the problem on? + multiple: true + options: + - Android + - iOS + - web + - Windows + - Linux + - macOS + - other + validations: + required: true + - type: textarea + id: platform-details + attributes: + label: Platform details + description: | + * It's ok to not implement the feature for every platform. On some it doesn't even make sense (e.g. Mobile vs. Desktop). Write down your thoughts! + * What needs to be changed to port an existing feature to other platforms? + * Will the feature require a specific API level or package to work? + validations: + required: false + - type: textarea + id: sources + attributes: + label: Audio Files/URLs/Sources + description: | + * Provide the files or URLs which are involved in your feature request, e.g. support a new file type. + * Make private URLs or actual files accessible, by uploading them (e.g. wrap them in a `zip` file for GitHub). + placeholder: | + Drag and drop your source files here or provide a link! + + * Sample Url: https://luan.xyz/files/audio/coins.wav + * Sample Stream: https://example.com/my_stream.m3u8 + validations: + required: false + - type: textarea + id: mockups + attributes: + label: Mockups + description: | + If applicable, add mockup screenshots or videos to help to explain what this feature would look like. + It can also be screenshots/screen recordings of existing apps, built using other libraries (like native libraries). + placeholder: Drag and drop your screenshots here! + validations: + required: false + - type: textarea + id: more-info + attributes: + label: Related issues / more information + description: | + Do you have any other useful information about this feature report? + References to other issues / sites / repositories / apps / tutorials with similar functionality? + placeholder: | + App xyz already provides this feature. + See abc repository for inspiration. + Related: #123, #456 + validations: + required: false + - type: dropdown + id: working-pull-request + attributes: + label: Working on PR + description: Are you interested in working on a PR for this? + multiple: false + # For some reason GH forms does not allow "yes" as option. + options: + - no way + - yeah + validations: + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..f1043b021 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,54 @@ +# Description + + + +## Checklist + + + +- [ ] The title of my PR starts with a [Conventional Commit] prefix (`fix:`, `feat:`, `refactor:`, + `docs:`, `chore:`, `test:`, `ci:` etc). +- [ ] I have read the [Contributor Guide] and followed the process outlined for submitting PRs. +- [ ] I have updated/added tests for ALL new/updated/fixed functionality. +- [ ] I have updated/added relevant documentation and added dartdoc comments with `///`, where necessary. +- [ ] I have updated/added relevant examples in [example]. + +## Breaking Change + + + +- [ ] Yes, this is a breaking change. +- [ ] No, this is *not* a breaking change. + + + + + +## Related Issues + + + + +[issue database]: https://github.com/bluefireteam/audioplayers/issues +[Contributor Guide]: https://github.com/bluefireteam/audioplayers/blob/main/contributing.md#feature-requests--prs +[Conventional Commit]: https://conventionalcommits.org +[example]: https://github.com/bluefireteam/audioplayers/tree/main/packages/audioplayers/example diff --git a/.github/workflows/build-example.yml b/.github/workflows/build-example.yml new file mode 100644 index 000000000..ce4ac3a33 --- /dev/null +++ b/.github/workflows/build-example.yml @@ -0,0 +1,211 @@ +name: build example +on: + workflow_dispatch: + inputs: + flutter_version: + description: 'Flutter Version' + required: false + default: 'any' + type: choice + options: + - 'any' + - '3.35.x' + - '3.32.x' + flutter_channel: + description: 'Flutter Channel' + required: false + default: 'stable' + type: choice + options: + - 'stable' + - 'beta' + - 'dev' + - 'master' + enable_android: + description: 'Build Android' + required: false + default: true + type: boolean + enable_web: + description: 'Build Web' + required: false + default: true + type: boolean + enable_ios: + description: 'Build IOS' + required: false + default: true + type: boolean + enable_windows: + description: 'Build Windows' + required: false + default: true + type: boolean + enable_linux: + description: 'Build Linux' + required: false + default: true + type: boolean + enable_macos: + description: 'Build MacOS' + required: false + default: true + type: boolean + upload_pages_artifact: + description: 'Upload build artifact for GH pages' + required: false + default: false + type: boolean + workflow_call: + inputs: + flutter_version: + required: false + default: '3.35.7' + type: string + flutter_channel: + required: false + default: 'stable' + type: string + enable_android: + required: false + default: true + type: boolean + enable_web: + required: false + default: true + type: boolean + enable_ios: + required: false + default: true + type: boolean + enable_windows: + required: false + default: true + type: boolean + enable_linux: + required: false + default: true + type: boolean + enable_macos: + required: false + default: true + type: boolean + upload_pages_artifact: + required: false + default: false + type: boolean + +jobs: + web: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.enable_web + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + + - name: Example app - Build Web app + working-directory: ./packages/audioplayers/example + run: flutter build web --base-href "/audioplayers/" + - name: Example app - Build Web app in WASM + working-directory: ./packages/audioplayers/example + run: flutter build web --base-href "/audioplayers/" --wasm + - name: Upload pages artifact + if: inputs.upload_pages_artifact + uses: actions/upload-pages-artifact@v3 + with: + path: ./packages/audioplayers/example/build/web + + android: + runs-on: ubuntu-latest + timeout-minutes: 60 + if: inputs.enable_android + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + + - name: Example App - Build Android APK + working-directory: ./packages/audioplayers/example + run: flutter build apk --release + + ios: + runs-on: macos-latest + timeout-minutes: 30 + if: inputs.enable_ios + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + + - name: Example app - Build iOS + working-directory: ./packages/audioplayers/example + run: flutter build ios --release --no-codesign + + macos: + runs-on: macos-latest + timeout-minutes: 30 + if: inputs.enable_macos + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + + - name: Example app - Build macOS + working-directory: ./packages/audioplayers/example + run: flutter build macos --release + + windows: + runs-on: windows-latest + timeout-minutes: 30 + if: inputs.enable_windows + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + + - name: Example app - Build Windows app + working-directory: ./packages/audioplayers/example + run: flutter build windows --release + + linux: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.enable_linux + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + + - name: Install Flutter requirements for Linux + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev + - name: Install GStreamer + # Install libunwind-dev, see https://github.com/actions/runner-images/issues/6399#issuecomment-1285011525 + run: | + sudo apt install -y libunwind-dev + sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev + + - name: Example app - Build Linux app + working-directory: ./packages/audioplayers/example + run: flutter build linux --release diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml deleted file mode 100644 index a50783722..000000000 --- a/.github/workflows/build.yaml +++ /dev/null @@ -1,127 +0,0 @@ -name: build -on: - push: - branches: - - master - pull_request: - branches: - - master - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@v1 - - uses: subosito/flutter-action@v1 - with: - channel: stable - - - name: Lint (analyze and format) - run: ./scripts/lint.sh - - - name: Dartdoc - run: ./scripts/dartdoc.sh - - - name: Run tests - run: ./scripts/test.sh - - web: - runs-on: ubuntu-latest - steps: - - uses: nanasess/setup-chromedriver@v1 - - uses: actions/checkout@v1 - - uses: subosito/flutter-action@v1 - with: - channel: stable - - - name: Example App - Build web app - working-directory: ./packages/audioplayers/example - run: | - flutter pub get - flutter build web - - - name: web integration test - working-directory: ./packages/audioplayers/example - run: | - export DISPLAY=:99 - chromedriver --port=4444 & - sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & # optional - - flutter drive \ - --driver=test_driver/integration_test.dart \ - --target=integration_test/app_test.dart \ - -d web-server - - android: - runs-on: macOS-latest - - steps: - - uses: malinskiy/action-android/install-sdk@release/0.1.2 - - uses: actions/checkout@v1 - - uses: subosito/flutter-action@v1 - with: - channel: stable - - - name: Use specific Java version for sdkmanager to work - uses: joschi/setup-jdk@v2 - with: - java-version: 'openjdk8' - architecture: 'x64' - - - name: Example App - Build android APK - working-directory: ./packages/audioplayers/example - run: | - flutter pub get - flutter build apk --release - - - name: Run android unit tests - working-directory: ./packages/audioplayers/example/android - run: | - ./gradlew test - - - name: Download Android Emulator Image - run: | - echo "y" | $ANDROID_HOME/tools/bin/sdkmanager --install "system-images;android-30;google_apis;x86" - echo "no" | $ANDROID_HOME/tools/bin/avdmanager create avd --force --name emu --device "Nexus 5X" -k 'system-images;android-30;google_apis;x86' - $ANDROID_HOME/emulator/emulator -list-avds - - name: "Start Android Emulator" - timeout-minutes: 10 - run: | - echo "Starting emulator" - ls "$ANDROID_HOME/tools/bin/" - $ANDROID_HOME/tools/bin/sdkmanager "platform-tools" "platforms;android-30" - echo "$ANDROID_HOME/platform-tools" - ls "$ANDROID_HOME/" - ls "$ANDROID_HOME/platform-tools" - nohup $ANDROID_HOME/emulator/emulator -avd emu -no-audio -no-snapshot -no-window & - $ANDROID_HOME/platform-tools/adb wait-for-device shell 'while [[ -z $(getprop sys.boot_completed | tr -d '\r') ]]; do sleep 1; done; input keyevent 82' - $ANDROID_HOME/platform-tools/adb devices - echo "Emulator started" - - name: "Run Flutter Driver tests" - working-directory: ./packages/audioplayers/example - run: "flutter test integration_test" - - ios: - runs-on: macOS-latest - - steps: - - name: List all simulators - run: "xcrun instruments -s" - - name: Start Simulator - run: | - UDID=$(xcrun instruments -s | grep "iPhone" | sed -n 1p | awk -F ' *[][]' '{ print $2 }') - echo "Using simulator $UUID" - xcrun simctl boot "${UDID:?No Simulator with this name iPhone found}" - - uses: actions/checkout@v1 - - uses: subosito/flutter-action@v1 - with: - channel: stable - - name: Example App - Build iOS - working-directory: ./packages/audioplayers/example - run: | - flutter pub get - flutter build ios --release --no-codesign - - name: Run Flutter Driver tests - working-directory: ./packages/audioplayers/example - run: "flutter test integration_test" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 000000000..535e9d10e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,40 @@ +name: build +on: + push: + branches: + - main + workflow_dispatch: + +# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. +# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. +concurrency: + group: "pages" + cancel-in-progress: false + +jobs: + call-build-example: + uses: ./.github/workflows/build-example.yml + with: + upload_pages_artifact: true + + call-test: + needs: call-build-example + uses: ./.github/workflows/test.yml + with: + enable_min_version: true + + deploy-github-pages: + needs: + - call-build-example + - call-test + 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/changes-requested.yml b/.github/workflows/changes-requested.yml index ad1fc3a8e..dd4714ebd 100644 --- a/.github/workflows/changes-requested.yml +++ b/.github/workflows/changes-requested.yml @@ -2,7 +2,7 @@ name: changes-requested on: schedule: - - cron: "0 * * * *" # pick a cron here, this is every 1h + - cron: "0 0 * * *" # pick a cron here, this is every day workflow_dispatch: jobs: @@ -13,8 +13,8 @@ jobs: with: github-token: ${{ secrets.GITHUB_TOKEN }} # these are optional, if you want to configure: - days-until-close: 5 + days-until-close: 7 trigger-label: changes-requested closing-comment: This issue was closed by the changes-requested bot due to inactivity. dry-run: false - \ No newline at end of file + diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml new file mode 100644 index 000000000..3660a871d --- /dev/null +++ b/.github/workflows/pull-request.yml @@ -0,0 +1,31 @@ +name: pull-request +on: + pull_request: + types: + - opened + - reopened + - synchronize + - ready_for_review + +jobs: + call-min-flutter-test: + uses: ./.github/workflows/test.yml + with: + flutter_version: '3.32.8' + fatal_warnings: false + enable_android: ${{ github.event.pull_request.draft == false }} + enable_web: ${{ github.event.pull_request.draft == false }} + enable_ios: ${{ github.event.pull_request.draft == false }} + enable_windows: ${{ github.event.pull_request.draft == false }} + enable_linux: ${{ github.event.pull_request.draft == false }} + enable_macos: ${{ github.event.pull_request.draft == false }} + call-test: + uses: ./.github/workflows/test.yml + with: + enable_android: ${{ github.event.pull_request.draft == false }} + enable_web: ${{ github.event.pull_request.draft == false }} + enable_ios: ${{ github.event.pull_request.draft == false }} + enable_windows: ${{ github.event.pull_request.draft == false }} + enable_linux: ${{ github.event.pull_request.draft == false }} + enable_macos: ${{ github.event.pull_request.draft == false }} + enable_min_version: true diff --git a/.github/workflows/release-prepare.yml b/.github/workflows/release-prepare.yml new file mode 100644 index 000000000..6cc8cdc74 --- /dev/null +++ b/.github/workflows/release-prepare.yml @@ -0,0 +1,28 @@ +name: Prepare release +on: + workflow_dispatch: + inputs: + prerelease: + description: 'Version as prerelease' + required: false + default: false + type: boolean + +jobs: + prepare-release: + name: Prepare release + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: subosito/flutter-action@v2 + - uses: bluefireteam/melos-action@v3 + with: + run-versioning: ${{ inputs.prerelease == false }} + run-versioning-prerelease: ${{ inputs.prerelease == true }} + publish-dry-run: true + create-pr: true diff --git a/.github/workflows/release-publish.yml b/.github/workflows/release-publish.yml new file mode 100644 index 000000000..5a1f0eecb --- /dev/null +++ b/.github/workflows/release-publish.yml @@ -0,0 +1,19 @@ +name: Publish packages +on: + workflow_dispatch: + +jobs: + publish-packages: + name: Publish packages + permissions: + contents: write + id-token: write # Required for authentication using OIDC + runs-on: [ ubuntu-latest ] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: subosito/flutter-action@v2 + - uses: bluefireteam/melos-action@v3 + with: + publish: true diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml new file mode 100644 index 000000000..6ea095df8 --- /dev/null +++ b/.github/workflows/release-tag.yml @@ -0,0 +1,28 @@ +name: Tag and start release +on: + push: + branches: [main] + +jobs: + publish-packages: + name: Create tags for release + permissions: + actions: write + contents: write + runs-on: [ ubuntu-latest ] + if: contains(github.event.head_commit.message, 'chore(release)') + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: subosito/flutter-action@v2 + - uses: bluefireteam/melos-action@v3 + with: + tag: true + - run: | + melos exec -c 1 --no-published --no-private --order-dependents -- \ + gh workflow run release-publish.yml \ + --ref \$MELOS_PACKAGE_NAME-v\$MELOS_PACKAGE_VERSION + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 000000000..f81f32767 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,20 @@ +name: stale + +on: + schedule: + - cron: "0 0 * * *" # pick a cron here, this is every day + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: luanpotter/changes-requested@master + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + # these are optional, if you want to configure: + days-until-close: 30 + trigger-label: stale + closing-comment: This issue was closed by the stale bot due to inactivity. + dry-run: false + diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 000000000..7de5a412c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,468 @@ +name: test +on: + workflow_dispatch: + inputs: + flutter_version: + description: 'Flutter Version' + required: false + default: 'any' + type: choice + options: + - 'any' + - '3.35.x' + - '3.32.x' + flutter_channel: + description: 'Flutter Channel' + required: false + default: 'stable' + type: choice + options: + - 'stable' + - 'beta' + - 'dev' + - 'master' + fatal_warnings: + description: 'Treat warnings as fatal' + required: false + default: true + type: boolean + enable_android: + description: 'Test Android' + required: false + default: true + type: boolean + enable_web: + description: 'Test Web' + required: false + default: true + type: boolean + enable_ios: + description: 'Test IOS' + required: false + default: true + type: boolean + enable_windows: + description: 'Test Windows' + required: false + default: true + type: boolean + enable_linux: + description: 'Test Linux' + required: false + default: true + type: boolean + enable_macos: + description: 'Test MacOS' + required: false + default: true + type: boolean + enable_min_version: + description: 'Test Platform min version' + required: false + default: false + type: boolean + + workflow_call: + inputs: + flutter_version: + required: false + default: '3.35.7' + type: string + flutter_channel: + required: false + default: 'stable' + type: string + fatal_warnings: + required: false + default: true + type: boolean + enable_android: + required: false + default: true + type: boolean + enable_web: + required: false + default: true + type: boolean + enable_ios: + required: false + default: true + type: boolean + enable_windows: + required: false + default: true + type: boolean + enable_linux: + required: false + default: true + type: boolean + enable_macos: + required: false + default: true + type: boolean + enable_min_version: + required: false + default: false + type: boolean + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + - uses: actions/checkout@v4 + with: + # Full git history needed for `super-linter` + fetch-depth: 0 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + + - run: melos format --set-exit-if-changed + - run: flutter analyze ${{ inputs.fatal_warnings && '--fatal-infos' || '--no-fatal-warnings' }} + - run: melos run test + + - name: Lint Code Base + uses: super-linter/super-linter/slim@v5 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + VALIDATE_ALL_CODEBASE: false + DEFAULT_BRANCH: main + VALIDATE_KOTLIN_ANDROID: true + VALIDATE_CLANG_FORMAT: true + - name: Lint Swift + # TODO: check if swift-format can be integrated in super-linter, as soon as Alpine is supported + # https://github.com/apple/swift-docker/issues/231 + # https://github.com/super-linter/super-linter/pull/4568 + run: | + docker run --rm --workdir=/work --volume=$PWD:/work mtgto/swift-format:5.8 \ + lint --parallel --strict --recursive packages/audioplayers_darwin + + web: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.enable_web + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + - uses: nanasess/setup-chromedriver@v2 + + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + run: | + export DISPLAY=:99 + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + + chromedriver --port=4444 & + + ( cd server; dart run bin/server.dart ) & + flutter drive \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/platform_test.dart \ + -d web-server \ + --web-browser-flag="--autoplay-policy=no-user-gesture-required" \ + --web-browser-flag="--disable-web-security" \ + --dart-define USE_LOCAL_SERVER=true + + flutter drive \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/lib_test.dart \ + -d web-server \ + --web-browser-flag="--autoplay-policy=no-user-gesture-required" \ + --web-browser-flag="--disable-web-security" \ + --dart-define USE_LOCAL_SERVER=true + + flutter drive \ + --driver=test_driver/integration_test.dart \ + --target=integration_test/app_test.dart \ + -d web-server \ + --web-browser-flag="--autoplay-policy=no-user-gesture-required" \ + --web-browser-flag="--disable-web-security" \ + --dart-define USE_LOCAL_SERVER=true + + android-min: + runs-on: ubuntu-latest + timeout-minutes: 90 + if: inputs.enable_min_version && inputs.enable_android + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + - name: Setup Android Emulator + timeout-minutes: 10 + run: bash ./scripts/ci/setup-android.sh 24 default + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + # Need to execute lib and app tests one by one, see: https://github.com/flutter/flutter/issues/101031 + run: | + ( cd server; dart run bin/server.dart ) & + flutter test integration_test/platform_test.dart --dart-define USE_LOCAL_SERVER=true --dart-define TEST_FEATURE_LOW_LATENCY=false --dart-define TEST_FEATURE_BYTES_SOURCE=false --dart-define TEST_FEATURE_PLAYBACK_RATE=false --dart-define TEST_ANDROID_MEDIAPLAYER=true + flutter test integration_test/lib_test.dart --dart-define USE_LOCAL_SERVER=true --dart-define TEST_FEATURE_LOW_LATENCY=false --dart-define TEST_FEATURE_BYTES_SOURCE=false --dart-define TEST_FEATURE_PLAYBACK_RATE=false --dart-define TEST_ANDROID_MEDIAPLAYER=true + flutter test integration_test/app_test.dart --dart-define USE_LOCAL_SERVER=true --dart-define TEST_FEATURE_LOW_LATENCY=false --dart-define TEST_FEATURE_BYTES_SOURCE=false --dart-define TEST_FEATURE_PLAYBACK_RATE=false --dart-define TEST_ANDROID_MEDIAPLAYER=true + + android-exo: + runs-on: ubuntu-latest + timeout-minutes: 90 + if: inputs.enable_android + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - name: Override endorsed plugin with audioplayers_android_exo + run: | + dart pub add "audioplayers_android_exo:{path: ../../audioplayers_android_exo}" + working-directory: ./packages/audioplayers/example + - uses: bluefireteam/melos-action@v3 + - name: Setup Android Emulator + timeout-minutes: 10 + run: bash ./scripts/ci/setup-android.sh 35 + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + # Need to execute lib and app tests one by one, see: https://github.com/flutter/flutter/issues/101031 + run: | + ( cd server; dart run bin/server.dart ) & + flutter test integration_test/platform_test.dart --dart-define USE_LOCAL_SERVER=true --dart-define TEST_FEATURE_LOW_LATENCY=false + flutter test integration_test/lib_test.dart --dart-define USE_LOCAL_SERVER=true --dart-define TEST_FEATURE_LOW_LATENCY=false + flutter test integration_test/app_test.dart --dart-define USE_LOCAL_SERVER=true --dart-define TEST_FEATURE_LOW_LATENCY=false + + android: + runs-on: ubuntu-latest + timeout-minutes: 90 + if: inputs.enable_android + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + - name: Setup Android Emulator + timeout-minutes: 10 + run: bash ./scripts/ci/setup-android.sh 35 + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + # Need to execute lib and app tests one by one, see: https://github.com/flutter/flutter/issues/101031 + run: | + ( cd server; dart run bin/server.dart ) & + flutter test integration_test/platform_test.dart --dart-define USE_LOCAL_SERVER=true --dart-define TEST_ANDROID_MEDIAPLAYER=true + flutter test integration_test/lib_test.dart --dart-define USE_LOCAL_SERVER=true --dart-define TEST_ANDROID_MEDIAPLAYER=true + flutter test integration_test/app_test.dart --dart-define USE_LOCAL_SERVER=true --dart-define TEST_ANDROID_MEDIAPLAYER=true + - name: Run Android unit tests + working-directory: ./packages/audioplayers/example/android + # TODO: Use `./gradlew test`, when https://github.com/flutter/flutter/issues/169336 is fixed. + run: ./gradlew testDebugUnitTest + + ios-min: + # Run lib tests only to ensure compatibility with iOS 16. + runs-on: macos-latest + timeout-minutes: 60 + if: inputs.enable_min_version && inputs.enable_ios + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + run: | + xcodes runtimes install "iOS 16.4" + UDID=$(xcrun simctl create test-se-16-4 com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation com.apple.CoreSimulator.SimRuntime.iOS-16-4) + xcrun simctl list devices + echo "Using simulator $UDID" + xcrun simctl boot "${UDID:?No Simulator with this name iPhone found}" + ( cd server; dart run bin/server.dart ) & + flutter test -d $UDID integration_test/lib_test.dart --dart-define USE_LOCAL_SERVER=true + + ios: + runs-on: macos-26 + timeout-minutes: 60 + if: inputs.enable_ios + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - run: flutter config --enable-swift-package-manager + - uses: bluefireteam/melos-action@main + + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + # Need to execute lib and app tests one by one, see: https://github.com/flutter/flutter/issues/101031 + run: | + UDID=$(xcrun simctl create test-se-26-2 com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation com.apple.CoreSimulator.SimRuntime.iOS-26-2) + xcrun simctl list devices + echo "Using simulator $UDID" + xcrun simctl boot "${UDID:?No Simulator with this name iPhone found}" + ( cd server; dart run bin/server.dart ) & + flutter test -d $UDID integration_test/platform_test.dart --dart-define USE_LOCAL_SERVER=true + flutter test -d $UDID integration_test/lib_test.dart --dart-define USE_LOCAL_SERVER=true + flutter test -d $UDID integration_test/app_test.dart --dart-define USE_LOCAL_SERVER=true + + # Remove as soon as support for cocoapods is removed at Flutter + ios-pods: + runs-on: macos-latest + timeout-minutes: 30 + if: inputs.enable_ios + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - run: flutter config --no-enable-swift-package-manager + - uses: bluefireteam/melos-action@v3 + + - name: Example app - Build iOS + working-directory: ./packages/audioplayers/example + run: flutter build ios --release --no-codesign + + macos-min: + runs-on: macos-14 + timeout-minutes: 30 + if: inputs.enable_min_version && inputs.enable_macos + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@main + + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + # Need to execute lib and app tests one by one, see: https://github.com/flutter/flutter/issues/101031 + run: | + ( cd server; dart run bin/server.dart ) & + flutter test -d macos integration_test/lib_test.dart --dart-define USE_LOCAL_SERVER=true + + macos: + runs-on: macos-latest + timeout-minutes: 30 + if: inputs.enable_macos + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - run: flutter config --enable-swift-package-manager + - uses: bluefireteam/melos-action@v3 + + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + # Need to execute lib and app tests one by one, see: https://github.com/flutter/flutter/issues/101031 + run: | + ( cd server; dart run bin/server.dart ) & + flutter test -d macos integration_test/platform_test.dart --dart-define USE_LOCAL_SERVER=true + flutter test -d macos integration_test/lib_test.dart --dart-define USE_LOCAL_SERVER=true + flutter test -d macos integration_test/app_test.dart --dart-define USE_LOCAL_SERVER=true + + # Remove as soon as support for cocoapods is removed at Flutter + macos-pods: + runs-on: macos-latest + timeout-minutes: 30 + if: inputs.enable_macos + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - run: flutter config --no-enable-swift-package-manager + - uses: bluefireteam/melos-action@v3 + + - name: Example app - Build macOS + working-directory: ./packages/audioplayers/example + run: flutter build macos --release + + windows: + runs-on: windows-latest + timeout-minutes: 30 + if: inputs.enable_windows + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + - name: Download virtual audio device + # Download has to be done before setting the system date time. + timeout-minutes: 1 + run: | + Invoke-WebRequest https://github.com/duncanthrax/scream/releases/download/4.0/Scream4.0.zip -OutFile Scream.zip + Expand-Archive -Path Scream.zip -DestinationPath Scream + - name: Disable time sync with Hyper-V & setting system date time (#1573) + # TODO(gustl22): Remove workaround of setting the time when virtual audio device certificate is valid again (#1573) + run: | + Set-Service -Name vmictimesync -Status stopped -StartupType disabled + Set-ItemProperty HKLM:\SYSTEM\CurrentControlSet\services\W32Time\Parameters -Name 'Type' -Value 'NoSync' + net stop w32time; Set-Date (Get-Date "2023-07-04 12:00:00") + - name: Install virtual audio device + timeout-minutes: 1 + run: | + Import-Certificate -FilePath Scream\Install\driver\x64\Scream.cat -CertStoreLocation Cert:\LocalMachine\TrustedPublisher + Scream\Install\helpers\devcon-x64.exe install Scream\Install\driver\x64\Scream.inf *Scream + - name: Resetting system date time (#1573) + run: | + Set-Service -Name vmictimesync -Status running -StartupType automatic + Set-ItemProperty HKLM:\SYSTEM\CurrentControlSet\services\W32Time\Parameters -Name 'Type' -Value 'NTP' + net start w32time; w32tm /resync /force; $currentDate = Get-Date; Write-Host "Current System Date: $currentDate"; + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + shell: bash # Needed in order to fail fast, see: https://github.com/actions/runner-images/issues/6668 + # Need to execute lib and app tests one by one, see: https://github.com/flutter/flutter/issues/101031 + run: | + ( cd server; dart run bin/server.dart ) & + flutter test -d windows integration_test/platform_test.dart --dart-define USE_LOCAL_SERVER=true + flutter test -d windows integration_test/lib_test.dart --dart-define USE_LOCAL_SERVER=true + flutter test -d windows integration_test/app_test.dart --dart-define USE_LOCAL_SERVER=true + + linux: + runs-on: ubuntu-latest + timeout-minutes: 30 + if: inputs.enable_linux + steps: + - uses: actions/checkout@v4 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ inputs.flutter_version }} + channel: ${{ inputs.flutter_channel }} + - uses: bluefireteam/melos-action@v3 + - name: Install Flutter requirements for Linux + run: | + sudo apt-get update + sudo apt-get install -y clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev + - name: Install GStreamer + # Install libunwind-dev, see https://github.com/actions/runner-images/issues/6399#issuecomment-1285011525 + run: | + sudo apt install -y libunwind-dev + sudo apt-get install -y libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev gstreamer1.0-plugins-good gstreamer1.0-plugins-bad + + - name: Run Flutter integration tests + working-directory: ./packages/audioplayers/example + # Need to execute lib and app tests one by one, see: https://github.com/flutter/flutter/issues/101031 + # TODO(gustl22): Linux tests are flaky with LIVE_MODE=false + run: | + export DISPLAY=:99 + sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 & + ( cd server; LIVE_MODE=true dart run bin/server.dart ) & + flutter test -d linux integration_test/platform_test.dart --dart-define USE_LOCAL_SERVER=true + flutter test -d linux integration_test/lib_test.dart --dart-define USE_LOCAL_SERVER=true + flutter test -d linux integration_test/app_test.dart --dart-define USE_LOCAL_SERVER=true diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..5f831f935 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.idea/ +*.iml +pubspec.lock +.dart_tool/ +pubspec_overrides.yaml +.flutter-plugins-dependencies diff --git a/.swift-format b/.swift-format new file mode 100644 index 000000000..5fccbe9ca --- /dev/null +++ b/.swift-format @@ -0,0 +1,4 @@ +{ + "_comment": "details can be found at: https://github.com/apple/swift-format/blob/main/Documentation/Configuration.md", + "version": 1 +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..717796907 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,1454 @@ +# Change Log + +All notable changes to this project will be documented in this file. +See [Conventional Commits](https://conventionalcommits.org) for commit guidelines. + +## 2025-09-03 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v6.5.1`](#audioplayers---v651) + +--- + +#### `audioplayers` - `v6.5.1` + + - **FIX**: Initialize audioplayer instances sequentially ([#1941](https://github.com/bluefireteam/audioplayers/issues/1941)). ([663fff2c](https://github.com/bluefireteam/audioplayers/commit/663fff2cb8482c81cb525c9d97bfb7f5d02dfdee)) + + +## 2025-06-15 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v6.5.0`](#audioplayers---v650) + - [`audioplayers_android` - `v5.2.1`](#audioplayers_android---v521) + - [`audioplayers_android_exo` - `v0.1.2+1`](#audioplayers_android_exo---v0121) + - [`audioplayers_darwin` - `v6.3.0`](#audioplayers_darwin---v630) + - [`audioplayers_linux` - `v4.2.1`](#audioplayers_linux---v421) + - [`audioplayers_platform_interface` - `v7.1.1`](#audioplayers_platform_interface---v711) + - [`audioplayers_web` - `v5.1.1`](#audioplayers_web---v511) + - [`audioplayers_windows` - `v4.2.1`](#audioplayers_windows---v421) + +--- + +#### `audioplayers` - `v6.5.0` + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + - **FEAT**: Customizable preparation and seeking timeout ([#1921](https://github.com/bluefireteam/audioplayers/issues/1921)). ([d5a63f8b](https://github.com/bluefireteam/audioplayers/commit/d5a63f8b206554f6a6719653c5ce9b92b8d096b9)) + - **FEAT**: Support for Swift Package Manager ([#1908](https://github.com/bluefireteam/audioplayers/issues/1908)). ([e8f86e7b](https://github.com/bluefireteam/audioplayers/commit/e8f86e7bf80ddb8b0955d35c53f08cbf5f2d141b)) + +#### `audioplayers_android` - `v5.2.1` + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +#### `audioplayers_android_exo` - `v0.1.2+1` + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +#### `audioplayers_darwin` - `v6.3.0` + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + - **FEAT**: Support for Swift Package Manager ([#1908](https://github.com/bluefireteam/audioplayers/issues/1908)). ([e8f86e7b](https://github.com/bluefireteam/audioplayers/commit/e8f86e7bf80ddb8b0955d35c53f08cbf5f2d141b)) + +#### `audioplayers_linux` - `v4.2.1` + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +#### `audioplayers_platform_interface` - `v7.1.1` + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +#### `audioplayers_web` - `v5.1.1` + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +#### `audioplayers_windows` - `v4.2.1` + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + + +## 2025-03-10 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v6.4.0`](#audioplayers---v640) + - [`audioplayers_android` - `v5.2.0`](#audioplayers_android---v520) + - [`audioplayers_android_exo` - `v0.1.2`](#audioplayers_android_exo---v012) + - [`audioplayers_darwin` - `v6.2.0`](#audioplayers_darwin---v620) + - [`audioplayers_linux` - `v4.2.0`](#audioplayers_linux---v420) + - [`audioplayers_platform_interface` - `v7.1.0`](#audioplayers_platform_interface---v710) + - [`audioplayers_web` - `v5.1.0`](#audioplayers_web---v510) + - [`audioplayers_windows` - `v4.2.0`](#audioplayers_windows---v420) + +--- + +#### `audioplayers` - `v6.4.0` + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +#### `audioplayers_android` - `v5.2.0` + + - **FIX**: Make FocusManager compatible with Android API <= 25 (closes [#1895](https://github.com/bluefireteam/audioplayers/issues/1895)) ([#1904](https://github.com/bluefireteam/audioplayers/issues/1904)). ([41238d48](https://github.com/bluefireteam/audioplayers/commit/41238d4837fb5c59b8aaf2e7e8087268a160ebe7)) + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +#### `audioplayers_android_exo` - `v0.1.2` + + - **FIX**: Make FocusManager compatible with Android API <= 25 (closes [#1895](https://github.com/bluefireteam/audioplayers/issues/1895)) ([#1904](https://github.com/bluefireteam/audioplayers/issues/1904)). ([41238d48](https://github.com/bluefireteam/audioplayers/commit/41238d4837fb5c59b8aaf2e7e8087268a160ebe7)) + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +#### `audioplayers_darwin` - `v6.2.0` + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +#### `audioplayers_linux` - `v4.2.0` + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +#### `audioplayers_platform_interface` - `v7.1.0` + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +#### `audioplayers_web` - `v5.1.0` + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +#### `audioplayers_windows` - `v4.2.0` + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + + +## 2025-03-06 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v6.3.0`](#audioplayers---v630) + - [`audioplayers_android` - `v5.1.0`](#audioplayers_android---v510) + - [`audioplayers_android_exo` - `v0.1.1`](#audioplayers_android_exo---v011) + - [`audioplayers_darwin` - `v6.1.1`](#audioplayers_darwin---v611) + - [`audioplayers_linux` - `v4.1.1`](#audioplayers_linux---v411) + - [`audioplayers_platform_interface` - `v7.0.1`](#audioplayers_platform_interface---v701) + - [`audioplayers_web` - `v5.0.2`](#audioplayers_web---v502) + - [`audioplayers_windows` - `v4.1.1`](#audioplayers_windows---v411) + +--- + +#### `audioplayers` - `v6.3.0` + + - **FEAT**(android): ExoPlayer for Android ([#1691](https://github.com/bluefireteam/audioplayers/issues/1691)). ([a91c5b18](https://github.com/bluefireteam/audioplayers/commit/a91c5b185054986a2390d41593b5ee502ef96bdd))\ + You can enable the non-endorsed plugin implementation based on ExoPlayer (Media3) by calling: `flutter pub add audioplayers_android_exo` in your app package folder. + For more, see: https://github.com/bluefireteam/audioplayers/blob/main/feature_parity_table.md#media3-exoplayer + - **FEAT**: Raise to Flutter 3.29.0 ([#1891](https://github.com/bluefireteam/audioplayers/issues/1891)). ([26bd6d22](https://github.com/bluefireteam/audioplayers/commit/26bd6d228deacf7cb3454f2d1b972585cd8bb6ea)) + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +#### `audioplayers_android` - `v5.1.0` + + - **FEAT**(android): ExoPlayer for Android ([#1691](https://github.com/bluefireteam/audioplayers/issues/1691)). ([a91c5b18](https://github.com/bluefireteam/audioplayers/commit/a91c5b185054986a2390d41593b5ee502ef96bdd)) + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +#### `audioplayers_android_exo` - `v0.1.1` + + - **FEAT**(android): ExoPlayer for Android ([#1691](https://github.com/bluefireteam/audioplayers/issues/1691)). ([a91c5b18](https://github.com/bluefireteam/audioplayers/commit/a91c5b185054986a2390d41593b5ee502ef96bdd)) + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +#### `audioplayers_darwin` - `v6.1.1` + + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +#### `audioplayers_linux` - `v4.1.1` + + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +#### `audioplayers_platform_interface` - `v7.0.1` + + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +#### `audioplayers_web` - `v5.0.2` + + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +#### `audioplayers_windows` - `v4.1.1` + + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + + +## 2025-02-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v6.2.0`](#audioplayers---v620) + - [`audioplayers_android` - `v5.0.3`](#audioplayers_android---v503) + - [`audioplayers_darwin` - `v6.1.0`](#audioplayers_darwin---v610) + - [`audioplayers_linux` - `v4.1.0`](#audioplayers_linux---v410) + - [`audioplayers_windows` - `v4.1.0`](#audioplayers_windows---v410) + +--- + +#### `audioplayers` - `v6.2.0` + + - **FIX**: No-op on single player setAudioContext in desktop platforms ([#1888](https://github.com/bluefireteam/audioplayers/issues/1888)). ([50d7a8b8](https://github.com/bluefireteam/audioplayers/commit/50d7a8b89f47e3ef29e98cf2b74a582f78783d5e)) + - **FEAT**: Support setting AudioContext in AudioPool ([#1890](https://github.com/bluefireteam/audioplayers/issues/1890)). ([2968c88b](https://github.com/bluefireteam/audioplayers/commit/2968c88b4e1492a29d4cd0e5f7735f159f995c1a)) + - **FEAT**: ReleaseMode.release for ios, macos, windows, web, linux ([#1790](https://github.com/bluefireteam/audioplayers/issues/1790)). ([4ffc4029](https://github.com/bluefireteam/audioplayers/commit/4ffc4029d846d7c391c457b829c372c1763b7b50)) + +#### `audioplayers_android` - `v5.0.3` + + - **FIX**: Raise Android SDK versions ([#1885](https://github.com/bluefireteam/audioplayers/issues/1885)). ([7230bc84](https://github.com/bluefireteam/audioplayers/commit/7230bc84d9dfb0cccfbe5bacb971ef3698495176)) + +#### `audioplayers_darwin` - `v6.1.0` + + - **FIX**: No-op on single player setAudioContext in desktop platforms ([#1888](https://github.com/bluefireteam/audioplayers/issues/1888)). ([50d7a8b8](https://github.com/bluefireteam/audioplayers/commit/50d7a8b89f47e3ef29e98cf2b74a582f78783d5e)) + - **FEAT**: ReleaseMode.release for ios, macos, windows, web, linux ([#1790](https://github.com/bluefireteam/audioplayers/issues/1790)). ([4ffc4029](https://github.com/bluefireteam/audioplayers/commit/4ffc4029d846d7c391c457b829c372c1763b7b50)) + +#### `audioplayers_linux` - `v4.1.0` + + - **FIX**: No-op on single player setAudioContext in desktop platforms ([#1888](https://github.com/bluefireteam/audioplayers/issues/1888)). ([50d7a8b8](https://github.com/bluefireteam/audioplayers/commit/50d7a8b89f47e3ef29e98cf2b74a582f78783d5e)) + - **FEAT**: ReleaseMode.release for ios, macos, windows, web, linux ([#1790](https://github.com/bluefireteam/audioplayers/issues/1790)). ([4ffc4029](https://github.com/bluefireteam/audioplayers/commit/4ffc4029d846d7c391c457b829c372c1763b7b50)) + +#### `audioplayers_windows` - `v4.1.0` + + - **FIX**: No-op on single player setAudioContext in desktop platforms ([#1888](https://github.com/bluefireteam/audioplayers/issues/1888)). ([50d7a8b8](https://github.com/bluefireteam/audioplayers/commit/50d7a8b89f47e3ef29e98cf2b74a582f78783d5e)) + - **FEAT**: ReleaseMode.release for ios, macos, windows, web, linux ([#1790](https://github.com/bluefireteam/audioplayers/issues/1790)). ([4ffc4029](https://github.com/bluefireteam/audioplayers/commit/4ffc4029d846d7c391c457b829c372c1763b7b50)) + + +## 2025-02-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers_android` - `v5.0.2`](#audioplayers_android---v502) + - [`audioplayers` - `v6.1.2`](#audioplayers---v612) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `audioplayers` - `v6.1.2` + +--- + +#### `audioplayers_android` - `v5.0.2` + + - **FIX**: Change audioFocus dynamically ([#1877](https://github.com/bluefireteam/audioplayers/issues/1877)). ([14f16d9d](https://github.com/bluefireteam/audioplayers/commit/14f16d9dfc52e2eca989e0cc6a27decb8e96af83)) + + +## 2025-01-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers_android` - `v5.0.1`](#audioplayers_android---v501) + - [`audioplayers_linux` - `v4.0.1`](#audioplayers_linux---v401) + - [`audioplayers` - `v6.1.1`](#audioplayers---v611) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `audioplayers` - `v6.1.1` + +--- + +#### `audioplayers_android` - `v5.0.1` + + - **FIX**: Avoid multiple audioFocusRequest instances for focus changes ([#1869](https://github.com/bluefireteam/audioplayers/issues/1869)). ([040dde9c](https://github.com/bluefireteam/audioplayers/commit/040dde9c2b1d4601a4c4790fa4a43a4cdd4e9a27)) + - **FIX**(android): Avoid playing after gaining focus in paused state ([#1857](https://github.com/bluefireteam/audioplayers/issues/1857)). ([01726c13](https://github.com/bluefireteam/audioplayers/commit/01726c1362135a4c3595169dcb1adb311f25f683)) + +#### `audioplayers_linux` - `v4.0.1` + + - **DOCS**: Add Fedora/RHEL Dependency instructions ([#1851](https://github.com/bluefireteam/audioplayers/issues/1851)). ([b401a23c](https://github.com/bluefireteam/audioplayers/commit/b401a23c934c93a78893bb2def011cd10508c33b)) + + +## 2024-08-26 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers_web` - `v5.0.1`](#audioplayers_web---v501) + - [`audioplayers` - `v6.1.0`](#audioplayers---v610) + +--- + +#### `audioplayers_web` - `v5.0.1` + + - **DEPS**: Consider web:1.0.0 ([#1828](https://github.com/bluefireteam/audioplayers/pull/1828)). ([9d25e78d](https://github.com/bluefireteam/audioplayers/commit/9d25e78d24a687c90ffa76f034c418d2bbe45251)) + +#### `audioplayers` - `v6.1.0` + + - **FEAT**: Upgrade to Flutter v3.22.x ([#1803](https://github.com/bluefireteam/audioplayers/issues/1803)). ([4d669e72](https://github.com/bluefireteam/audioplayers/commit/4d669e723dc5c2399073301ba9333f99bc623669)) + + +## 2024-03-19 + +### Changes + +--- + +Packages with breaking changes: + + - [`audioplayers` - `v6.0.0`](#audioplayers---v600) + - [`audioplayers_android` - `v5.0.0`](#audioplayers_android---v500) + - [`audioplayers_darwin` - `v6.0.0`](#audioplayers_darwin---v600) + - [`audioplayers_linux` - `v4.0.0`](#audioplayers_linux---v400) + - [`audioplayers_platform_interface` - `v7.0.0`](#audioplayers_platform_interface---v700) + - [`audioplayers_web` - `v5.0.0`](#audioplayers_web---v500) + - [`audioplayers_windows` - `v4.0.0`](#audioplayers_windows---v400) + +Packages with other changes: + + - There are no other changes in this release. + +--- + +#### `audioplayers` - `v6.0.0` + + - **FIX**: Use unique tmp location for each AudioCache ([#1724](https://github.com/bluefireteam/audioplayers/issues/1724)). ([2333cb7f](https://github.com/bluefireteam/audioplayers/commit/2333cb7f5a9fcd84bdd477120d1f53f346c3b10d)) + - **FIX**: Race condition when playing/pausing audio ([#1705](https://github.com/bluefireteam/audioplayers/issues/1705)). ([463b2a11](https://github.com/bluefireteam/audioplayers/commit/463b2a1149105a25f81d708533d13cc2dd277d6b)) + - **FIX**: Seek not applied in `play` method ([#1695](https://github.com/bluefireteam/audioplayers/issues/1695)). ([f6138fef](https://github.com/bluefireteam/audioplayers/commit/f6138fef97ccd5b78b44dbe85f7d41e16b3662f6)) + - **FIX**: Propagate Stream Errors through the same Future ([#1732](https://github.com/bluefireteam/audioplayers/issues/1732)). ([00d041df](https://github.com/bluefireteam/audioplayers/commit/00d041df11c26fd96f480782f2787f857c77daa0)) + - **FIX**: Wait for seek to complete ([#1712](https://github.com/bluefireteam/audioplayers/issues/1712)). ([fd33b1d0](https://github.com/bluefireteam/audioplayers/commit/fd33b1d073280797cdd88fb6324cc1906bfd5957)) + - **FEAT**: Support byte array and data URIs via mimeType ([#1763](https://github.com/bluefireteam/audioplayers/issues/1763)). ([eaf7ce86](https://github.com/bluefireteam/audioplayers/commit/eaf7ce86ad271097365fcf9e3a03fc341629ae47)) + - **FEAT**(ios): Improved AudioContextConfig assertions, fix example ([#1619](https://github.com/bluefireteam/audioplayers/issues/1619)). ([df342c52](https://github.com/bluefireteam/audioplayers/commit/df342c529b0b13abd0515c5dc762987293ebc4c1)) + - **FEAT**(web): Support compilation to Wasm ([#1766](https://github.com/bluefireteam/audioplayers/issues/1766)). ([1b1a0cf9](https://github.com/bluefireteam/audioplayers/commit/1b1a0cf92e950bc520598426d3f073c3bd5a6a28)) + - **DOCS**: Improve Docs ([#1710](https://github.com/bluefireteam/audioplayers/issues/1710)). ([4208463a](https://github.com/bluefireteam/audioplayers/commit/4208463a4110ed117eebe28e170872817712ff53)) + - **BREAKING** **REFACTOR**: Remove deprecated methods ([#1583](https://github.com/bluefireteam/audioplayers/issues/1583)). ([8d0cbeda](https://github.com/bluefireteam/audioplayers/commit/8d0cbeda6babea69b1753340f9cec3d246d7e29a)) + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **FEAT**: Extend `AudioContextConfig.duckAudio` to `AudioContextConfig.focus` ([#1720](https://github.com/bluefireteam/audioplayers/issues/1720)). ([87f3cb7e](https://github.com/bluefireteam/audioplayers/commit/87f3cb7e47e2103d2079a3dfe6aebe80c8a76c3d)) + - **BREAKING** **FEAT**(ios): Improve AudioContextIOS ([#1591](https://github.com/bluefireteam/audioplayers/issues/1591)). ([25fbec05](https://github.com/bluefireteam/audioplayers/commit/25fbec051a4f521f73c473cdad20f88c7907d7b1)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + - **BREAKING** **CHORE**: Upgrade to Flutter 3.13.0 ([#1612](https://github.com/bluefireteam/audioplayers/issues/1612)). ([1a3de1ac](https://github.com/bluefireteam/audioplayers/commit/1a3de1acd5a8b90b6d9c0d0f2a7141723c277c24)) + +#### `audioplayers_android` - `v5.0.0` + + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + +#### `audioplayers_darwin` - `v6.0.0` + + - **FIX**(ios): 'audioProcessing' deprecated in iOS 10 ([#1756](https://github.com/bluefireteam/audioplayers/issues/1756)). ([81e5ea54](https://github.com/bluefireteam/audioplayers/commit/81e5ea542578f27c558f9a049996ecd8cb95c002)) + - **FEAT**: Support byte array and data URIs via mimeType ([#1763](https://github.com/bluefireteam/audioplayers/issues/1763)). ([eaf7ce86](https://github.com/bluefireteam/audioplayers/commit/eaf7ce86ad271097365fcf9e3a03fc341629ae47)) + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + +#### `audioplayers_linux` - `v4.0.0` + + - **FIX**(linux): Handle failures of OnMediaStateChange in OnMediaError ([#1731](https://github.com/bluefireteam/audioplayers/issues/1731)). ([3a5c6dca](https://github.com/bluefireteam/audioplayers/commit/3a5c6dca5dd9476765a976724e3ca89574794cb0)) + - **FIX**: Wait for seek to complete ([#1712](https://github.com/bluefireteam/audioplayers/issues/1712)). ([fd33b1d0](https://github.com/bluefireteam/audioplayers/commit/fd33b1d073280797cdd88fb6324cc1906bfd5957)) + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + - **BREAKING** **CHORE**: Upgrade to Flutter 3.13.0 ([#1612](https://github.com/bluefireteam/audioplayers/issues/1612)). ([1a3de1ac](https://github.com/bluefireteam/audioplayers/commit/1a3de1acd5a8b90b6d9c0d0f2a7141723c277c24)) + +#### `audioplayers_platform_interface` - `v7.0.0` + + - **FEAT**: Support byte array and data URIs via mimeType ([#1763](https://github.com/bluefireteam/audioplayers/issues/1763)). ([eaf7ce86](https://github.com/bluefireteam/audioplayers/commit/eaf7ce86ad271097365fcf9e3a03fc341629ae47)) + - **FEAT**(ios): Improved AudioContextConfig assertions, fix example ([#1619](https://github.com/bluefireteam/audioplayers/issues/1619)). ([df342c52](https://github.com/bluefireteam/audioplayers/commit/df342c529b0b13abd0515c5dc762987293ebc4c1)) + - **DOCS**: Improve Docs ([#1710](https://github.com/bluefireteam/audioplayers/issues/1710)). ([4208463a](https://github.com/bluefireteam/audioplayers/commit/4208463a4110ed117eebe28e170872817712ff53)) + - **BREAKING** **FEAT**: Extend `AudioContextConfig.duckAudio` to `AudioContextConfig.focus` ([#1720](https://github.com/bluefireteam/audioplayers/issues/1720)). ([87f3cb7e](https://github.com/bluefireteam/audioplayers/commit/87f3cb7e47e2103d2079a3dfe6aebe80c8a76c3d)) + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **FEAT**(ios): Improve AudioContextIOS ([#1591](https://github.com/bluefireteam/audioplayers/issues/1591)). ([25fbec05](https://github.com/bluefireteam/audioplayers/commit/25fbec051a4f521f73c473cdad20f88c7907d7b1)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + +#### `audioplayers_web` - `v5.0.0` + + - **FEAT**: Support byte array and data URIs via mimeType ([#1763](https://github.com/bluefireteam/audioplayers/issues/1763)). ([eaf7ce86](https://github.com/bluefireteam/audioplayers/commit/eaf7ce86ad271097365fcf9e3a03fc341629ae47)) + - **FEAT**(web): Support compilation to Wasm ([#1766](https://github.com/bluefireteam/audioplayers/issues/1766)). ([1b1a0cf9](https://github.com/bluefireteam/audioplayers/commit/1b1a0cf92e950bc520598426d3f073c3bd5a6a28)) + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + +#### `audioplayers_windows` - `v4.0.0` + + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + + +## 2023-11-14 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v5.2.1`](#audioplayers---v521) + - [`audioplayers_android` - `v4.0.3`](#audioplayers_android---v403) + +--- + +#### `audioplayers` - `v5.2.1` + + - **FIX**: Avoid decoding already encoded character in URI ([#1679](https://github.com/bluefireteam/audioplayers/issues/1679)). ([1923205c](https://github.com/bluefireteam/audioplayers/commit/1923205c4cde70e2915e6e6c6afeb2fec27a08e8)) + - **FIX**(android): Released wrong source in LOW_LATENCY mode ([#1672](https://github.com/bluefireteam/audioplayers/issues/1672)). ([d9c5f693](https://github.com/bluefireteam/audioplayers/commit/d9c5f693cafab21b67b785de6244c3c371344a53)) + +#### `audioplayers_android` - `v4.0.3` + + - **FIX**(android): Released wrong source in LOW_LATENCY mode ([#1672](https://github.com/bluefireteam/audioplayers/issues/1672)). ([d9c5f693](https://github.com/bluefireteam/audioplayers/commit/d9c5f693cafab21b67b785de6244c3c371344a53)) + + +## 2023-10-02 + +### Changes + +--- + +Packages with other changes: + + - [`audioplayers` - `v5.2.0`](#audioplayers---v520) + - [`audioplayers_android` - `v4.0.2`](#audioplayers_android---v402) + - [`audioplayers_darwin` - `v5.0.2`](#audioplayers_darwin---v502) + - [`audioplayers_linux` - `v3.1.0`](#audioplayers_linux---v310) + - [`audioplayers_platform_interface` - `v6.1.0`](#audioplayers_platform_interface---v610) + - [`audioplayers_web` - `v4.1.0`](#audioplayers_web---v410) + - [`audioplayers_windows` - `v3.1.0`](#audioplayers_windows---v310) + +--- + +#### `audioplayers` - `v5.2.0` + + - **REFACTOR**: Lint Swift ([#1613](https://github.com/bluefireteam/audioplayers/issues/1613)). ([737aa94f](https://github.com/bluefireteam/audioplayers/commit/737aa94f7edb076d622c34e498b90f17c9959e9c)) + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Cancel `onPreparedSubscription` on error ([#1660](https://github.com/bluefireteam/audioplayers/issues/1660)). ([c11dbf30](https://github.com/bluefireteam/audioplayers/commit/c11dbf3094457799a3b89fd6f0b386799b2f943c)) + - **FIX**: Set playback rate only when playing ([#1658](https://github.com/bluefireteam/audioplayers/issues/1658)). ([d73c7d5c](https://github.com/bluefireteam/audioplayers/commit/d73c7d5c2ef13e8eff2c438b96ade6e2483a2014)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + - **FEAT**(windows): Support for BytesSource on Windows ([#1601](https://github.com/bluefireteam/audioplayers/issues/1601)). ([a9e14710](https://github.com/bluefireteam/audioplayers/commit/a9e147107aa31072d4bcc69a02b2ee287d4b366b)) + - **FEAT**: Allow adding custom media sources to example ([#1637](https://github.com/bluefireteam/audioplayers/issues/1637)). ([1eabe619](https://github.com/bluefireteam/audioplayers/commit/1eabe61957caf969f132ce6fad7b99208887466b)) + - **DOCS**: Deploy live example app to GH pages ([#1623](https://github.com/bluefireteam/audioplayers/issues/1623)). ([fe81f3b1](https://github.com/bluefireteam/audioplayers/commit/fe81f3b1e600fe005febbe7cd3da02735a3de004)) + +#### `audioplayers_linux` - `v3.1.0` + + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + - **FEAT**: Release source for Web, Linux, Windows ([#1517](https://github.com/bluefireteam/audioplayers/issues/1517)). ([09496dcb](https://github.com/bluefireteam/audioplayers/commit/09496dcbf478af330e37be833184439b43b5ac44)) + - **DOCS**: Manual Flutter installation on Linux setup ([#1631](https://github.com/bluefireteam/audioplayers/issues/1631)). ([9086e75a](https://github.com/bluefireteam/audioplayers/commit/9086e75a9503bdb84f372b5e09a4b225d3fae5f6)) + +#### `audioplayers_platform_interface` - `v6.1.0` + + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + - **FEAT**: create, dispose & reuse event stream ([#1609](https://github.com/bluefireteam/audioplayers/issues/1609)). ([efbabf5c](https://github.com/bluefireteam/audioplayers/commit/efbabf5cb30de0013fe3b67cb7206de602f1dc84)) + +#### `audioplayers_android` - `v4.0.2` + + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Set playback rate only when playing ([#1658](https://github.com/bluefireteam/audioplayers/issues/1658)). ([d73c7d5c](https://github.com/bluefireteam/audioplayers/commit/d73c7d5c2ef13e8eff2c438b96ade6e2483a2014)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + +#### `audioplayers_darwin` - `v5.0.2` + + - **REFACTOR**: Lint Swift ([#1613](https://github.com/bluefireteam/audioplayers/issues/1613)). ([737aa94f](https://github.com/bluefireteam/audioplayers/commit/737aa94f7edb076d622c34e498b90f17c9959e9c)) + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Set playback rate only when playing ([#1658](https://github.com/bluefireteam/audioplayers/issues/1658)). ([d73c7d5c](https://github.com/bluefireteam/audioplayers/commit/d73c7d5c2ef13e8eff2c438b96ade6e2483a2014)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**(darwin): Start observing `AVPlayerItem.status` before being assigned to `AVPlayer` ([#1549](https://github.com/bluefireteam/audioplayers/issues/1549)). ([8c3a2138](https://github.com/bluefireteam/audioplayers/commit/8c3a213841c063d4a45bdb96e339ac338c7c8758)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + +#### `audioplayers_web` - `v4.1.0` + + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FEAT**: Release source for Web, Linux, Windows ([#1517](https://github.com/bluefireteam/audioplayers/issues/1517)). ([09496dcb](https://github.com/bluefireteam/audioplayers/commit/09496dcbf478af330e37be833184439b43b5ac44)) + +#### `audioplayers_windows` - `v3.1.0` + + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + - **FEAT**(windows): Support for BytesSource on Windows ([#1601](https://github.com/bluefireteam/audioplayers/issues/1601)). ([a9e14710](https://github.com/bluefireteam/audioplayers/commit/a9e147107aa31072d4bcc69a02b2ee287d4b366b)) + - **FEAT**: Release source for Web, Linux, Windows ([#1517](https://github.com/bluefireteam/audioplayers/issues/1517)). ([09496dcb](https://github.com/bluefireteam/audioplayers/commit/09496dcbf478af330e37be833184439b43b5ac44)) + + +## 2023-08-09 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v5.1.0`](#audioplayers---v510) + - [`audioplayers_android` - `v4.0.1`](#audioplayers_android---v401) + - [`audioplayers_darwin` - `v5.0.1`](#audioplayers_darwin---v501) + +--- + +#### `audioplayers` - `v5.1.0` + + - **REFACTOR**(darwin): Rearrange code ([#1585](https://github.com/bluefireteam/audioplayers/issues/1585)). ([13639d1f](https://github.com/bluefireteam/audioplayers/commit/13639d1f2fe5afbc17f4e862e2da0f7551b8fc3e)) + - **FEAT**: Get current volume, balance and playbackRate ([#1582](https://github.com/bluefireteam/audioplayers/issues/1582)). ([0c2ff7b1](https://github.com/bluefireteam/audioplayers/commit/0c2ff7b1289238150388e571396ac92b28a8ea5d)) + +#### `audioplayers_android` - `v4.0.1` + + - **REVERT**(android): Upgrade androidx.core:core-ktx, restore support for AGP7 ([#1590](https://github.com/bluefireteam/audioplayers/issues/1590)). ([f6bf1260](https://github.com/bluefireteam/audioplayers/commit/f6bf12609ec9e457451f1c786522bff28a1555f4)) + +#### `audioplayers_darwin` - `v5.0.1` + + - **REFACTOR**(darwin): Rearrange code ([#1585](https://github.com/bluefireteam/audioplayers/issues/1585)). ([13639d1f](https://github.com/bluefireteam/audioplayers/commit/13639d1f2fe5afbc17f4e862e2da0f7551b8fc3e)) + + +## 2023-07-23 + +### Changes + +--- + +Packages with breaking changes: + + - [`audioplayers` - `v5.0.0`](#audioplayers---v500) + - [`audioplayers_android` - `v4.0.0`](#audioplayers_android---v400) + - [`audioplayers_darwin` - `v5.0.0`](#audioplayers_darwin---v500) + - [`audioplayers_linux` - `v3.0.0`](#audioplayers_linux---v300) + - [`audioplayers_platform_interface` - `v6.0.0`](#audioplayers_platform_interface---v600) + - [`audioplayers_web` - `v4.0.0`](#audioplayers_web---v400) + - [`audioplayers_windows` - `v3.0.0`](#audioplayers_windows---v300) + +Packages with other changes: + + - There are no other changes in this release. + +--- + +#### `audioplayers` - `v5.0.0` + + - **REFACTOR**(windows): simplify position and duration processing ([#1553](https://github.com/bluefireteam/audioplayers/issues/1553)). ([ca63c5a4](https://github.com/bluefireteam/audioplayers/commit/ca63c5a4b120e0d1ea421e6ab30f590c314a33f2)) + - **FIX**(example): Use kotlin version compatible with AGP8 ([#1577](https://github.com/bluefireteam/audioplayers/issues/1577)). ([8f4b1bb0](https://github.com/bluefireteam/audioplayers/commit/8f4b1bb0bc93df095bff2a4d4c2f92a4c4a85d17)) + - **FIX**(linux): allow reusing event channel with same name ([#1555](https://github.com/bluefireteam/audioplayers/issues/1555)). ([5471189f](https://github.com/bluefireteam/audioplayers/commit/5471189f9469e973f9262a120b02b321ca0dce24)) + - **FEAT**(android): Add support for AGP 8 in example, add compileOptions to build.gradle ([#1503](https://github.com/bluefireteam/audioplayers/issues/1503)). ([7c08e4e1](https://github.com/bluefireteam/audioplayers/commit/7c08e4e1a524f53294f6967996fd31837e62cb81)) + - **BREAKING** **FIX**: Default audio output to system preferences ([#1563](https://github.com/bluefireteam/audioplayers/issues/1563)). ([381c43e3](https://github.com/bluefireteam/audioplayers/commit/381c43e3725fbb0cb4fd35982893a3c92b188886)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +#### `audioplayers_android` - `v4.0.0` + + - **FIX**(android): Allow AudioFocus.none ([#1534](https://github.com/bluefireteam/audioplayers/issues/1534)). ([858d3f44](https://github.com/bluefireteam/audioplayers/commit/858d3f4410b1bc7b203090c20cf60b5136dad4fe)) + - **FEAT**(android): Add support for AGP 8 in example, add compileOptions to build.gradle ([#1503](https://github.com/bluefireteam/audioplayers/issues/1503)). ([7c08e4e1](https://github.com/bluefireteam/audioplayers/commit/7c08e4e1a524f53294f6967996fd31837e62cb81)) + - **BREAKING** **FIX**: Default audio output to system preferences ([#1563](https://github.com/bluefireteam/audioplayers/issues/1563)). ([381c43e3](https://github.com/bluefireteam/audioplayers/commit/381c43e3725fbb0cb4fd35982893a3c92b188886)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +#### `audioplayers_darwin` - `v5.0.0` + + - **BREAKING** **FIX**: Default audio output to system preferences ([#1563](https://github.com/bluefireteam/audioplayers/issues/1563)). ([381c43e3](https://github.com/bluefireteam/audioplayers/commit/381c43e3725fbb0cb4fd35982893a3c92b188886)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +#### `audioplayers_linux` - `v3.0.0` + + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +#### `audioplayers_platform_interface` - `v6.0.0` + + - **FIX**(android): Allow AudioFocus.none ([#1534](https://github.com/bluefireteam/audioplayers/issues/1534)). ([858d3f44](https://github.com/bluefireteam/audioplayers/commit/858d3f4410b1bc7b203090c20cf60b5136dad4fe)) + - **BREAKING** **FIX**: Default audio output to system preferences ([#1563](https://github.com/bluefireteam/audioplayers/issues/1563)). ([381c43e3](https://github.com/bluefireteam/audioplayers/commit/381c43e3725fbb0cb4fd35982893a3c92b188886)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +#### `audioplayers_web` - `v4.0.0` + + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +#### `audioplayers_windows` - `v3.0.0` + + - **REFACTOR**(windows): simplify position and duration processing ([#1553](https://github.com/bluefireteam/audioplayers/issues/1553)). ([ca63c5a4](https://github.com/bluefireteam/audioplayers/commit/ca63c5a4b120e0d1ea421e6ab30f590c314a33f2)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + + +## 2023-05-29 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v4.1.0`](#audioplayers---v410) + - [`audioplayers_android` - `v3.0.2`](#audioplayers_android---v302) + - [`audioplayers_darwin` - `v4.1.0`](#audioplayers_darwin---v410) + - [`audioplayers_linux` - `v2.1.0`](#audioplayers_linux---v210) + - [`audioplayers_platform_interface` - `v5.0.1`](#audioplayers_platform_interface---v501) + - [`audioplayers_web` - `v3.1.0`](#audioplayers_web---v310) + - [`audioplayers_windows` - `v2.0.2`](#audioplayers_windows---v202) + +--- + +#### `audioplayers` - `v4.1.0` + + - **REFACTOR**: Adapt to flame_lint v0.2.0+2 ([#1477](https://github.com/bluefireteam/audioplayers/issues/1477)). ([e1d7fb6a](https://github.com/bluefireteam/audioplayers/commit/e1d7fb6ab57c8a523c80dfc673bde3b7379b2add)) + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + - **FEAT**: Adapt position update interval of darwin, linux, web ([#1492](https://github.com/bluefireteam/audioplayers/issues/1492)). ([ab5bdf6a](https://github.com/bluefireteam/audioplayers/commit/ab5bdf6a2bcbf7e984d4d897e43a67b3684c52d8)) + - **DOCS**: Improve docs ([#1518](https://github.com/bluefireteam/audioplayers/issues/1518)). ([4c0d5546](https://github.com/bluefireteam/audioplayers/commit/4c0d55465a8e75c13987b970dee648657eba4384)) + +#### `audioplayers_android` - `v3.0.2` + + - **FIX**(android): `onComplete` is not called when audio has completed playing ([#1523](https://github.com/bluefireteam/audioplayers/issues/1523)). ([293d6c0e](https://github.com/bluefireteam/audioplayers/commit/293d6c0eec1d89ad200b2914cae0adf644b25013)) + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**(android): Add AGP 8 support with namespace property ([#1514](https://github.com/bluefireteam/audioplayers/issues/1514)). ([8d7b322e](https://github.com/bluefireteam/audioplayers/commit/8d7b322e79fd802fb75ca72f5c8ac388754cd406)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + +#### `audioplayers_darwin` - `v4.1.0` + + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + - **FEAT**: Adapt position update interval of darwin, linux, web ([#1492](https://github.com/bluefireteam/audioplayers/issues/1492)). ([ab5bdf6a](https://github.com/bluefireteam/audioplayers/commit/ab5bdf6a2bcbf7e984d4d897e43a67b3684c52d8)) + +#### `audioplayers_linux` - `v2.1.0` + + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + - **FEAT**: Adapt position update interval of darwin, linux, web ([#1492](https://github.com/bluefireteam/audioplayers/issues/1492)). ([ab5bdf6a](https://github.com/bluefireteam/audioplayers/commit/ab5bdf6a2bcbf7e984d4d897e43a67b3684c52d8)) + +#### `audioplayers_platform_interface` - `v5.0.1` + + - **FIX**: AudioEvent missing `isPrepared` logic ([#1521](https://github.com/bluefireteam/audioplayers/issues/1521)). ([1fa46c2c](https://github.com/bluefireteam/audioplayers/commit/1fa46c2cd28a4640c4aae65deee91ffe46cc4425)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + - **DOCS**: Improve doc for 'AudioContextConfig.respectSilence' ([#1490](https://github.com/bluefireteam/audioplayers/issues/1490)) ([#1500](https://github.com/bluefireteam/audioplayers/issues/1500)). ([415dda3b](https://github.com/bluefireteam/audioplayers/commit/415dda3b1621c57ea4b0366187f27f6a189555bf)) + +#### `audioplayers_web` - `v3.1.0` + + - **REFACTOR**: Adapt to flame_lint v0.2.0+2 ([#1477](https://github.com/bluefireteam/audioplayers/issues/1477)). ([e1d7fb6a](https://github.com/bluefireteam/audioplayers/commit/e1d7fb6ab57c8a523c80dfc673bde3b7379b2add)) + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: `AudioElement` is not getting released correctly ([#1516](https://github.com/bluefireteam/audioplayers/issues/1516)). ([32210f34](https://github.com/bluefireteam/audioplayers/commit/32210f34b186b44cc9c0484d7f67641162b325f6)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + - **FIX**(web): Avoid stutter when starting playback ([#1476](https://github.com/bluefireteam/audioplayers/issues/1476)). ([a28eed02](https://github.com/bluefireteam/audioplayers/commit/a28eed02f4e67e372d2b8f7c5bb271ffe6e09ec8)) + - **FEAT**: Adapt position update interval of darwin, linux, web ([#1492](https://github.com/bluefireteam/audioplayers/issues/1492)). ([ab5bdf6a](https://github.com/bluefireteam/audioplayers/commit/ab5bdf6a2bcbf7e984d4d897e43a67b3684c52d8)) + +#### `audioplayers_windows` - `v2.0.2` + + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + + +## 2023-04-12 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v4.0.1`](#audioplayers---v401) + - [`audioplayers_android` - `v3.0.1`](#audioplayers_android---v301) + - [`audioplayers_darwin` - `v4.0.1`](#audioplayers_darwin---v401) + - [`audioplayers_linux` - `v2.0.1`](#audioplayers_linux---v201) + - [`audioplayers_web` - `v3.0.1`](#audioplayers_web---v301) + - [`audioplayers_windows` - `v2.0.1`](#audioplayers_windows---v201) + +--- + +#### `audioplayers` - `v4.0.1` + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +#### `audioplayers_android` - `v3.0.1` + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +#### `audioplayers_darwin` - `v4.0.1` + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +#### `audioplayers_linux` - `v2.0.1` + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +#### `audioplayers_web` - `v3.0.1` + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +#### `audioplayers_windows` - `v2.0.1` + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + + +## 2023-04-10 + +### Changes + +--- + +Packages with breaking changes: + + - [`audioplayers` - `v4.0.0`](#audioplayers---v400) + - [`audioplayers_android` - `v3.0.0`](#audioplayers_android---v300) + - [`audioplayers_darwin` - `v4.0.0`](#audioplayers_darwin---v400) + - [`audioplayers_linux` - `v2.0.0`](#audioplayers_linux---v200) + - [`audioplayers_platform_interface` - `v5.0.0`](#audioplayers_platform_interface---v500) + - [`audioplayers_web` - `v3.0.0`](#audioplayers_web---v300) + - [`audioplayers_windows` - `v2.0.0`](#audioplayers_windows---v200) + +Packages with other changes: + + - There are no other changes in this release. + +--- + +#### `audioplayers` - `v4.0.0` + + - **FIX**(android): Avoid calling onDuration on position event (closes [#136](https://github.com/bluefireteam/audioplayers/issues/136)) ([#1460](https://github.com/bluefireteam/audioplayers/issues/1460)). ([6cfb3753](https://github.com/bluefireteam/audioplayers/commit/6cfb3753cd8003f341d97e0b2417d4512f452267)) + - **FEAT**: replace `Platform.isX` with `defaultTargetPlatform` ([#1446](https://github.com/bluefireteam/audioplayers/issues/1446)). ([6cd5656c](https://github.com/bluefireteam/audioplayers/commit/6cd5656c0c5deaab1fb4af78a5b7632402c3a1d3)) + - **FEAT**(example): add invalid asset, small refactor, colored source buttons ([#1445](https://github.com/bluefireteam/audioplayers/issues/1445)). ([92a20fad](https://github.com/bluefireteam/audioplayers/commit/92a20fadd6f549d44b7055b38a48fad2861a05c8)) + - **FEAT**(android): add `setBalance` ([#58](https://github.com/bluefireteam/audioplayers/issues/58)) ([#1444](https://github.com/bluefireteam/audioplayers/issues/1444)). ([3b5de50e](https://github.com/bluefireteam/audioplayers/commit/3b5de50ea7fa5248165616fc1ffd80da6c66583a)) + - **FEAT**: extract AudioContext from audio_context_config ([#1440](https://github.com/bluefireteam/audioplayers/issues/1440)). ([e59c3b9f](https://github.com/bluefireteam/audioplayers/commit/e59c3b9f07c1a72f9bf4e424fa3b011645f191d2)) + - **FEAT**(ios): set player context globally on `setAudioContext` for iOS only ([#1416](https://github.com/bluefireteam/audioplayers/issues/1416)). ([19af364b](https://github.com/bluefireteam/audioplayers/commit/19af364b7d0404ae436c54cdaa18d50f3a2aacd6)) + - **FEAT**(example): update app icons ([#1417](https://github.com/bluefireteam/audioplayers/issues/1417)). ([ac35df89](https://github.com/bluefireteam/audioplayers/commit/ac35df895cefe3d69dac4c8b1cf07c7f7ed56ca7)) + - **FEAT**: AudioPool (moved and improved from flame_audio) ([#1403](https://github.com/bluefireteam/audioplayers/issues/1403)). ([ab15cb02](https://github.com/bluefireteam/audioplayers/commit/ab15cb02cf939347772ac9fc961b5f01d7bad94b)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **DOCS**: update example app and screenshots ([#1419](https://github.com/bluefireteam/audioplayers/issues/1419)). ([c48eaf38](https://github.com/bluefireteam/audioplayers/commit/c48eaf389ab5b1cf1d51fadc814f473b8ea813cb)) + - **BREAKING** **REFACTOR**: prevent from confusing and conflicting class names ([#1465](https://github.com/bluefireteam/audioplayers/issues/1465)). ([7cdb8586](https://github.com/bluefireteam/audioplayers/commit/7cdb858605f24f0abd1a225e04922830233f3e96)) + - **BREAKING** **REFACTOR**: improve separation of global audioplayer interface ([#1443](https://github.com/bluefireteam/audioplayers/issues/1443)). ([c0b3f85c](https://github.com/bluefireteam/audioplayers/commit/c0b3f85c477f0313299cc2a2898840d6c7d8dcd9)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + - **BREAKING** **FEAT**: expose classes of package `audioplayers_platform_interface` ([#1442](https://github.com/bluefireteam/audioplayers/issues/1442)). ([a6f89be1](https://github.com/bluefireteam/audioplayers/commit/a6f89be181b7bd664eaf96cb9509bbc5adf5dbb9)) + +##### Migration instructions + +| Before | After | +|---|---| +| deprecated `AudioPlayer.global.changeLogLevel(LogLevel.info)` | `AudioLogger.logLevel = AudioLogLevel.info` | +| deprecated `AudioPlayer.global.logLevel` | `AudioLogger.logLevel` | +| deprecated `AudioPlayer.global.log()` | `AudioLogger.log()` or `AudioLogger.error()` | +| deprecated `AudioPlayer.global.info()` | `AudioLogger.log()` | +| deprecated `AudioPlayer.global.error()` | `AudioLogger.error()` | +| `GlobalPlatformInterface` | `GlobalAudioScope` | +| deprecated `AudioPlayer.global.setGlobalAudioContext()` | `AudioPlayer.global.setAudioContext()` | +| `ForPlayer<>` | _removed_ | + +#### `audioplayers_android` - `v3.0.0` + + - **FIX**(android): Avoid calling onDuration on position event (closes [#136](https://github.com/bluefireteam/audioplayers/issues/136)) ([#1460](https://github.com/bluefireteam/audioplayers/issues/1460)). ([6cfb3753](https://github.com/bluefireteam/audioplayers/commit/6cfb3753cd8003f341d97e0b2417d4512f452267)) + - **FIX**(android): reset prepared state on player error ([#1425](https://github.com/bluefireteam/audioplayers/issues/1425)). ([6f24c8f5](https://github.com/bluefireteam/audioplayers/commit/6f24c8f57e4549edbf7d68a021d1d94371c23f3f)) + - **FEAT**(android): add `setBalance` ([#58](https://github.com/bluefireteam/audioplayers/issues/58)) ([#1444](https://github.com/bluefireteam/audioplayers/issues/1444)). ([3b5de50e](https://github.com/bluefireteam/audioplayers/commit/3b5de50ea7fa5248165616fc1ffd80da6c66583a)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + +#### `audioplayers_darwin` - `v4.0.0` + + - **FIX**(iOS): Default to speaker instead of earpiece on iOS ([#1408](https://github.com/bluefireteam/audioplayers/issues/1408)). ([4ea5907b](https://github.com/bluefireteam/audioplayers/commit/4ea5907bfe5ce83a0d1c100acfc0760d00c2b448)) + - **FEAT**(ios): set player context globally on `setAudioContext` for iOS only ([#1416](https://github.com/bluefireteam/audioplayers/issues/1416)). ([19af364b](https://github.com/bluefireteam/audioplayers/commit/19af364b7d0404ae436c54cdaa18d50f3a2aacd6)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + +#### `audioplayers_linux` - `v2.0.0` + + - **FEAT**(windows): show nuget download info explicitely in verbose mode ([#1449](https://github.com/bluefireteam/audioplayers/issues/1449)). ([136028fa](https://github.com/bluefireteam/audioplayers/commit/136028fa1cbcf38f80e9cc7ad78b3bb89d2c6d30)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **DOCS**: Fix LICENSE files for windows and linux ([#1431](https://github.com/bluefireteam/audioplayers/issues/1431)). ([1f84e857](https://github.com/bluefireteam/audioplayers/commit/1f84e857a112e663fff73c4e7c6875ebb72c783d)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + +#### `audioplayers_platform_interface` - `v5.0.0` + + - **FEAT**: replace `Platform.isX` with `defaultTargetPlatform` ([#1446](https://github.com/bluefireteam/audioplayers/issues/1446)). ([6cd5656c](https://github.com/bluefireteam/audioplayers/commit/6cd5656c0c5deaab1fb4af78a5b7632402c3a1d3)) + - **FEAT**: extract AudioContext from audio_context_config ([#1440](https://github.com/bluefireteam/audioplayers/issues/1440)). ([e59c3b9f](https://github.com/bluefireteam/audioplayers/commit/e59c3b9f07c1a72f9bf4e424fa3b011645f191d2)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **BREAKING** **REFACTOR**: prevent from confusing and conflicting class names ([#1465](https://github.com/bluefireteam/audioplayers/issues/1465)). ([7cdb8586](https://github.com/bluefireteam/audioplayers/commit/7cdb858605f24f0abd1a225e04922830233f3e96)) + - **BREAKING** **REFACTOR**: improve separation of global audioplayer interface ([#1443](https://github.com/bluefireteam/audioplayers/issues/1443)). ([c0b3f85c](https://github.com/bluefireteam/audioplayers/commit/c0b3f85c477f0313299cc2a2898840d6c7d8dcd9)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + - **BREAKING** **FEAT**: expose classes of package `audioplayers_platform_interface` ([#1442](https://github.com/bluefireteam/audioplayers/issues/1442)). ([a6f89be1](https://github.com/bluefireteam/audioplayers/commit/a6f89be181b7bd664eaf96cb9509bbc5adf5dbb9)) + + +##### Migration instructions + +**audioplayers_platform_interface**: +| Before | After | +|---|---| +| `LogLevel` | _moved_ to `audioplayers` package as `AudioLogLevel` | +| `AudioplayersPlatform` | `AudioplayersPlatformInterface` | +| `MethodChannelAudioplayersPlatform` | `AudioplayersPlatform` | +| `GlobalPlatformInterface` | `GlobalAudioplayersPlatformInterface` | +| `MethodChannelGlobalPlatform` | `GlobalAudioplayersPlatform` | +| `StreamsInterface` | _removed_ | +| `ForPlayer<>` | _removed_ | + + +#### `audioplayers_web` - `v3.0.0` + + - **FIX**(web): make start and resume async ([#1436](https://github.com/bluefireteam/audioplayers/issues/1436)). ([b95bc8fa](https://github.com/bluefireteam/audioplayers/commit/b95bc8fa176e0d28a4d3d5ba6d26cafe699f1540)) + - **FEAT**: extract AudioContext from audio_context_config ([#1440](https://github.com/bluefireteam/audioplayers/issues/1440)). ([e59c3b9f](https://github.com/bluefireteam/audioplayers/commit/e59c3b9f07c1a72f9bf4e424fa3b011645f191d2)) + - **FEAT**(web): make setUrl async, make properties of `WrappedPlayer` private ([#1439](https://github.com/bluefireteam/audioplayers/issues/1439)). ([a051c335](https://github.com/bluefireteam/audioplayers/commit/a051c335a6cc0d1f6314f3f0c9f637920c3d6360)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **BREAKING** **REFACTOR**: prevent from confusing and conflicting class names ([#1465](https://github.com/bluefireteam/audioplayers/issues/1465)). ([7cdb8586](https://github.com/bluefireteam/audioplayers/commit/7cdb858605f24f0abd1a225e04922830233f3e96)) + - **BREAKING** **REFACTOR**: improve separation of global audioplayer interface ([#1443](https://github.com/bluefireteam/audioplayers/issues/1443)). ([c0b3f85c](https://github.com/bluefireteam/audioplayers/commit/c0b3f85c477f0313299cc2a2898840d6c7d8dcd9)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + - **BREAKING** **FEAT**: expose classes of package `audioplayers_platform_interface` ([#1442](https://github.com/bluefireteam/audioplayers/issues/1442)). ([a6f89be1](https://github.com/bluefireteam/audioplayers/commit/a6f89be181b7bd664eaf96cb9509bbc5adf5dbb9)) + +##### Migration instructions + +**audioplayers_web**: +| Before | After | +|---|---| +| `AudioplayersPlugin` | `AudioplayersPlugin`, `WebAudioplayersPlatform` and `WebGlobalAudioplayersPlatform` | + +#### `audioplayers_windows` - `v2.0.0` + + - **FEAT**(windows): show nuget download info explicitely in verbose mode ([#1449](https://github.com/bluefireteam/audioplayers/issues/1449)). ([136028fa](https://github.com/bluefireteam/audioplayers/commit/136028fa1cbcf38f80e9cc7ad78b3bb89d2c6d30)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **DOCS**: Fix LICENSE files for windows and linux ([#1431](https://github.com/bluefireteam/audioplayers/issues/1431)). ([1f84e857](https://github.com/bluefireteam/audioplayers/commit/1f84e857a112e663fff73c4e7c6875ebb72c783d)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + + +## 2023-01-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers_darwin` - `v3.0.1`](#audioplayers_darwin---v301) + - [`audioplayers` - `v3.0.1`](#audioplayers---v301) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `audioplayers` - `v3.0.1` + +--- + +#### `audioplayers_darwin` - `v3.0.1` + + - Fix duplicated path_providers plugins + + +## 2023-01-24 + +### Changes + +--- + +Packages with breaking changes: + + - [`audioplayers` - `v3.0.0`](#audioplayers---v300) + - [`audioplayers_android` - `v2.0.0`](#audioplayers_android---v200) + - [`audioplayers_platform_interface` - `v4.0.0`](#audioplayers_platform_interface---v400) + +Packages with other changes: + + - [`audioplayers_web` - `v2.2.0`](#audioplayers_web---v220) + +--- + +#### `audioplayers` - `v3.0.0` + + - **FEAT**: add and remove player actions ([#1394](https://github.com/bluefireteam/audioplayers/issues/1394)). ([f06cab91](https://github.com/bluefireteam/audioplayers/commit/f06cab91fbae65d7fdc9e3fbd75171b391ac0b96)) + - **FEAT**: example improvements ([#1392](https://github.com/bluefireteam/audioplayers/issues/1392)). ([002e2fc9](https://github.com/bluefireteam/audioplayers/commit/002e2fc950145e3231ab79a5ef399024d62f6fb1)) + - **BREAKING** **REFACTOR**: rename logger_platform_interface.dart to global_platform_interface.dart ([#1385](https://github.com/bluefireteam/audioplayers/issues/1385)). ([6e837c1c](https://github.com/bluefireteam/audioplayers/commit/6e837c1ccd93b95d10843a403674128cf303c0ab)) + - **BREAKING** **FEAT**: configurable SoundPool and `AudioManager.mode` ([#1388](https://github.com/bluefireteam/audioplayers/issues/1388)). ([5697f187](https://github.com/bluefireteam/audioplayers/commit/5697f187bcca64de2e519f8f49aaf4817fcf6398)) + +#### `audioplayers_android` - `v2.0.0` + + - **FIX**: playing at playback rate `1.0` in android API level < 23 (fixes [#1344](https://github.com/bluefireteam/audioplayers/issues/1344)) ([#1390](https://github.com/bluefireteam/audioplayers/issues/1390)). ([b248e71d](https://github.com/bluefireteam/audioplayers/commit/b248e71dabf923072f1fd14355b4e0230c9a6593)) + - **BREAKING** **FEAT**: configurable SoundPool and `AudioManager.mode` ([#1388](https://github.com/bluefireteam/audioplayers/issues/1388)). ([5697f187](https://github.com/bluefireteam/audioplayers/commit/5697f187bcca64de2e519f8f49aaf4817fcf6398)) + +#### `audioplayers_platform_interface` - `v4.0.0` + + - **BREAKING** **REFACTOR**: rename logger_platform_interface.dart to global_platform_interface.dart ([#1385](https://github.com/bluefireteam/audioplayers/issues/1385)). ([6e837c1c](https://github.com/bluefireteam/audioplayers/commit/6e837c1ccd93b95d10843a403674128cf303c0ab)) + - **BREAKING** **FEAT**: configurable SoundPool and `AudioManager.mode` ([#1388](https://github.com/bluefireteam/audioplayers/issues/1388)). ([5697f187](https://github.com/bluefireteam/audioplayers/commit/5697f187bcca64de2e519f8f49aaf4817fcf6398)) + +#### `audioplayers_web` - `v2.2.0` + + - **FIX**: use external factory for classes tagged with "@staticInterop" ([#1379](https://github.com/bluefireteam/audioplayers/issues/1379)). ([21d70504](https://github.com/bluefireteam/audioplayers/commit/21d7050455351b0c4ead9a3e2efbc8857115f247)) + + +## 2023-01-10 + +### Changes + +--- + +Packages with breaking changes: + + - [`audioplayers` - `v2.0.0`](#audioplayers---v200) + - [`audioplayers_darwin` - `v2.0.0`](#audioplayers_darwin---v200) + - [`audioplayers_platform_interface` - `v3.0.0`](#audioplayers_platform_interface---v300) + +Packages with other changes: + + - [`audioplayers_android` - `v1.1.4`](#audioplayers_android---v114) + - [`audioplayers_windows` - `v1.1.2`](#audioplayers_windows---v112) + - [`audioplayers_linux` - `v1.0.3`](#audioplayers_linux---v103) + - [`audioplayers_web` - `v2.1.1`](#audioplayers_web---v211) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `audioplayers_android` - `v1.1.4` + - `audioplayers_windows` - `v1.1.2` + - `audioplayers_linux` - `v1.0.3` + - `audioplayers_web` - `v2.1.1` + +--- + +#### `audioplayers` - `v2.0.0` + + - **BREAKING** **FIX**: remove unused `defaultToSpeaker` in `AudioContextIOS` and replace with `AVAudioSessionOptions.defaultToSpeaker` ([#1374](https://github.com/bluefireteam/audioplayers/issues/1374)). ([d844ef9d](https://github.com/bluefireteam/audioplayers/commit/d844ef9def06fd5047076d9f4c371ad3be4c8dd5)) + +#### `audioplayers_darwin` - `v2.0.0` + + - **BREAKING** **FIX**: remove unused `defaultToSpeaker` in `AudioContextIOS` and replace with `AVAudioSessionOptions.defaultToSpeaker` ([#1374](https://github.com/bluefireteam/audioplayers/issues/1374)). ([d844ef9d](https://github.com/bluefireteam/audioplayers/commit/d844ef9def06fd5047076d9f4c371ad3be4c8dd5)) + +#### `audioplayers_platform_interface` - `v3.0.0` + + - **BREAKING** **FIX**: remove unused `defaultToSpeaker` in `AudioContextIOS` and replace with `AVAudioSessionOptions.defaultToSpeaker` ([#1374](https://github.com/bluefireteam/audioplayers/issues/1374)). ([d844ef9d](https://github.com/bluefireteam/audioplayers/commit/d844ef9def06fd5047076d9f4c371ad3be4c8dd5)) + + +## 2023-01-01 + +### Changes + +--- + +Packages with breaking changes: + + - [`audioplayers` - `v1.2.0`](#audioplayers---v120) + - [`audioplayers_platform_interface` - `v2.1.0`](#audioplayers_platform_interface---v210) + +Packages with other changes: + + - [`audioplayers_android` - `v1.1.3`](#audioplayers_android---v113) + - [`audioplayers_darwin` - `v1.0.4`](#audioplayers_darwin---v104) + - [`audioplayers_linux` - `v1.0.2`](#audioplayers_linux---v102) + - [`audioplayers_web` - `v2.1.0`](#audioplayers_web---v210) + - [`audioplayers_windows` - `v1.1.1`](#audioplayers_windows---v111) + +--- + +#### `audioplayers` - `v1.2.0` + + - **FIX**: Duration precision on Windows ([#1342](https://github.com/bluefireteam/audioplayers/issues/1342)). ([3cda1a65](https://github.com/bluefireteam/audioplayers/commit/3cda1a65dc0425c332ed2eb3619cd88531f0ea49)) + - **FIX**: infinity / nan on getDuration ([#1298](https://github.com/bluefireteam/audioplayers/issues/1298)). ([a4474dcf](https://github.com/bluefireteam/audioplayers/commit/a4474dcf5e14fbd74db8b4f19223b9bfa40ed5f5)) + - **FEAT**: upgrade flutter to v3.0.0 and dart 2.17 to support "Super initializers" ([#1355](https://github.com/bluefireteam/audioplayers/issues/1355)). ([4af417b4](https://github.com/bluefireteam/audioplayers/commit/4af417b4c91ed5c22d6c48e05080c3018ccaee42)) + - **FEAT**: local test server ([#1354](https://github.com/bluefireteam/audioplayers/issues/1354)). ([06be429a](https://github.com/bluefireteam/audioplayers/commit/06be429a0078456a989b9afc3abc68164c4abaab)) + - **FEAT**: get current source ([#1350](https://github.com/bluefireteam/audioplayers/issues/1350)). ([7a10be38](https://github.com/bluefireteam/audioplayers/commit/7a10be38ec6613c8ef45bb33d1e81f11bb5988f9)) + - **FEAT**: log path and url of sources ([#1334](https://github.com/bluefireteam/audioplayers/issues/1334)). ([8a13f96d](https://github.com/bluefireteam/audioplayers/commit/8a13f96dbb14be0d1d80577816246109c42b7983)) + - **FEAT**: add setBalance ([#58](https://github.com/bluefireteam/audioplayers/issues/58)) ([#1282](https://github.com/bluefireteam/audioplayers/issues/1282)). ([782fc9df](https://github.com/bluefireteam/audioplayers/commit/782fc9dff24a2ab9681496fd7c4c8fed451eac35)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + - **BREAKING** **FIX**: Cache should take key to be properly cleared ([#1347](https://github.com/bluefireteam/audioplayers/issues/1347)). ([1a410bba](https://github.com/bluefireteam/audioplayers/commit/1a410bba578af506637b026bb2c4ace03a161a69)) + +#### `audioplayers_platform_interface` - `v2.1.0` + + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + - **BREAKING** **FIX**: Change the default value of iOS audio context to force speakers ([#1363](https://github.com/bluefireteam/audioplayers/issues/1363)). ([cb16c12d](https://github.com/bluefireteam/audioplayers/commit/cb16c12d35655bbde5cd94ae1d6f2a03fd6eba1e)) + +#### `audioplayers_android` - `v1.1.3` + + - **FIX**: Avoid ConcurrentModificationException ([#1297](https://github.com/bluefireteam/audioplayers/issues/1297)). ([d15ef5ab](https://github.com/bluefireteam/audioplayers/commit/d15ef5ab93f11e2f19089af08f1533fcdc1397e6)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + +#### `audioplayers_darwin` - `v1.0.4` + + - **FIX**: infinity / nan on getDuration ([#1298](https://github.com/bluefireteam/audioplayers/issues/1298)). ([a4474dcf](https://github.com/bluefireteam/audioplayers/commit/a4474dcf5e14fbd74db8b4f19223b9bfa40ed5f5)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + +#### `audioplayers_linux` - `v1.0.2` + + - **FIX**: play sound, when initialized ([#1332](https://github.com/bluefireteam/audioplayers/issues/1332)). ([2ed91fee](https://github.com/bluefireteam/audioplayers/commit/2ed91feec4d3528a4edff635331bd3aad938afd7)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + +#### `audioplayers_web` - `v2.1.0` + + - **FIX**: handle infinite value on getDuration for live streams ([#1287](https://github.com/bluefireteam/audioplayers/issues/1287)). ([15f2c78f](https://github.com/bluefireteam/audioplayers/commit/15f2c78f79a68349fe33ac1a26ffc67cfaaf1211)) + - **FEAT**: add setBalance ([#58](https://github.com/bluefireteam/audioplayers/issues/58)) ([#1282](https://github.com/bluefireteam/audioplayers/issues/1282)). ([782fc9df](https://github.com/bluefireteam/audioplayers/commit/782fc9dff24a2ab9681496fd7c4c8fed451eac35)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + +#### `audioplayers_windows` - `v1.1.1` + + - **FIX**: Duration precision on Windows ([#1342](https://github.com/bluefireteam/audioplayers/issues/1342)). ([3cda1a65](https://github.com/bluefireteam/audioplayers/commit/3cda1a65dc0425c332ed2eb3619cd88531f0ea49)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + + +## 2022-10-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v1.1.1`](#audioplayers---v111) + - [`audioplayers_android` - `v1.1.1`](#audioplayers_android---v111) + - [`audioplayers_darwin` - `v1.0.3`](#audioplayers_darwin---v103) + - [`audioplayers_web` - `v2.0.1`](#audioplayers_web---v201) + +--- + +#### `audioplayers` - `v1.1.1` + + - **FIX**: infinity / nan on getDuration ([#1298](https://github.com/bluefireteam/audioplayers/issues/1298)). ([a4474dcf](https://github.com/bluefireteam/audioplayers/commit/a4474dcf5e14fbd74db8b4f19223b9bfa40ed5f5)) + +#### `audioplayers_android` - `v1.1.1` + + - **FIX**: Avoid ConcurrentModificationException ([#1297](https://github.com/bluefireteam/audioplayers/issues/1297)). ([d15ef5ab](https://github.com/bluefireteam/audioplayers/commit/d15ef5ab93f11e2f19089af08f1533fcdc1397e6)) + +#### `audioplayers_darwin` - `v1.0.3` + + - **FIX**: infinity / nan on getDuration ([#1298](https://github.com/bluefireteam/audioplayers/issues/1298)). ([a4474dcf](https://github.com/bluefireteam/audioplayers/commit/a4474dcf5e14fbd74db8b4f19223b9bfa40ed5f5)) + +#### `audioplayers_web` - `v2.0.1` + + - **FIX**: handle infinite value on getDuration for live streams ([#1287](https://github.com/bluefireteam/audioplayers/issues/1287)). ([15f2c78f](https://github.com/bluefireteam/audioplayers/commit/15f2c78f79a68349fe33ac1a26ffc67cfaaf1211)) + + +## 2022-09-26 + +### Changes + +--- + +Packages with breaking changes: + + - [`audioplayers_platform_interface` - `v2.0.0`](#audioplayers_platform_interface---v200) + - [`audioplayers_web` - `v2.0.0`](#audioplayers_web---v200) + +Packages with other changes: + + - [`audioplayers` - `v1.1.0`](#audioplayers---v110) + - [`audioplayers_android` - `v1.1.0`](#audioplayers_android---v110) + - [`audioplayers_darwin` - `v1.0.2`](#audioplayers_darwin---v102) + - [`audioplayers_linux` - `v1.0.1`](#audioplayers_linux---v101) + - [`audioplayers_windows` - `v1.1.0`](#audioplayers_windows---v110) + +--- + +#### `audioplayers_platform_interface` - `v2.0.0` + + - **FIX**: handle platform exception via logger (#1254). ([56df6edf](https://github.com/bluefireteam/audioplayers/commit/56df6edfa1475e471c322c1180fd6f47d99c6610)) + - **BREAKING** **REFACTOR**: remove unused playerStateStream (#1280). ([27f9de22](https://github.com/bluefireteam/audioplayers/commit/27f9de224c7bc1f948356e917bf8b9c411fe9742)) + +#### `audioplayers_web` - `v2.0.0` + + - **FIX**: bugs from integration tests (#1268). ([d849c67f](https://github.com/bluefireteam/audioplayers/commit/d849c67f6916fb3800998d7d3f1c2752a5b9b9e7)) + - **FIX**: reset position, when stop or playing ended (#1246). ([d56f40fb](https://github.com/bluefireteam/audioplayers/commit/d56f40fbe89d2a5399f8cd0041b15150d6f72e01)) + - **FIX**: handle infinite duration (#1192). ([1d1600ba](https://github.com/bluefireteam/audioplayers/commit/1d1600bae372b1e07bd12966cd36571b6809d96a)) + - **BREAKING** **REFACTOR**: remove unused playerStateStream (#1280). ([27f9de22](https://github.com/bluefireteam/audioplayers/commit/27f9de224c7bc1f948356e917bf8b9c411fe9742)) + +#### `audioplayers` - `v1.1.0` + + - **FIX**: player state not being updated to completed (#1257). ([70a37afb](https://github.com/bluefireteam/audioplayers/commit/70a37afb6ce4fbb8b8c680ca9b6804b005012446)) + - **FIX**: lowLatency bugs (closes #1176, closes #1193, closes #1165) (#1272). ([541578cc](https://github.com/bluefireteam/audioplayers/commit/541578cc50f3856c23c393faa1a71380b3b49222)) + - **FIX**: ios/macos no longer start audio when calling only setSourceUrl (#1206). ([c0e97f04](https://github.com/bluefireteam/audioplayers/commit/c0e97f04fb05fb109830d6363f5c44dccbd327b4)) + - **FEAT**: improve example (#1267). ([a8154da1](https://github.com/bluefireteam/audioplayers/commit/a8154da1cc6fdec80d80fa538d65cb491a33db78)) + - **FEAT**: Platform integration tests 🤖 (#1128). ([b0c84aab](https://github.com/bluefireteam/audioplayers/commit/b0c84aabea8af28f693941c1b3bf2b1fa1048833)) + - **DOCS**: Remove 11-month old outdated doc file (#1180). ([bae43cb1](https://github.com/bluefireteam/audioplayers/commit/bae43cb10a27eff23ebaf2a6ac796fd61039f359)) + +#### `audioplayers_android` - `v1.1.0` + + - **FIX**: lowLatency bugs (closes #1176, closes #1193, closes #1165) (#1272). ([541578cc](https://github.com/bluefireteam/audioplayers/commit/541578cc50f3856c23c393faa1a71380b3b49222)) + - **FIX**: revert compileSdkVersion to be compatible with flutter.compileSdkVersion (#1273). ([0b9fed43](https://github.com/bluefireteam/audioplayers/commit/0b9fed43d9dfa90870826dc9a34d1a0d730bd78d)) + - **FIX**: emit onPositionChanged when seek is completed (closes #1259) (#1265). ([be7ac6a9](https://github.com/bluefireteam/audioplayers/commit/be7ac6a957fccadf5bcecf0f1fbea197d32bda21)) + - **FIX**: bugs from integration tests (#1247). ([6fad1cc4](https://github.com/bluefireteam/audioplayers/commit/6fad1cc4443e623e5c94519f130b4004b2dc3857)) + - **FIX**: Fix lowLatency mode for Android (#1193) (#1224). ([a25ca284](https://github.com/bluefireteam/audioplayers/commit/a25ca284835252147c85944575c7e71a3ef6abc4)) + - **FEAT**: wait for source to be prepared (#1191). ([5eeca894](https://github.com/bluefireteam/audioplayers/commit/5eeca8940e764546023567fa2f6b1bc3802f97d3)) + +#### `audioplayers_darwin` - `v1.0.2` + + - **FIX**: update platform to 9.0 in podspec. (#1171). ([f8cbd972](https://github.com/bluefireteam/audioplayers/commit/f8cbd972b56b75c8cf204af38f953f322dc98ab1)) + - **FIX**: ios/macos no longer start audio when calling only setSourceUrl (#1206). ([c0e97f04](https://github.com/bluefireteam/audioplayers/commit/c0e97f04fb05fb109830d6363f5c44dccbd327b4)) + +#### `audioplayers_linux` - `v1.0.1` + + - **FIX**: emit position event immediately when resume (#1222). ([94c73482](https://github.com/bluefireteam/audioplayers/commit/94c73482b0141d5f6c202219948fc79bac40b288)) + - **DOCS**: update README, Linux: replace with symlink, update Requirements (#1190). ([72e3d500](https://github.com/bluefireteam/audioplayers/commit/72e3d50067e274a8efb6b646a3318ae5fa097a77)) + +#### `audioplayers_windows` - `v1.1.0` + + - **FIX**: send onDuration event when play/resume (#1245). ([8108ff42](https://github.com/bluefireteam/audioplayers/commit/8108ff42d05c7f995d8289345302c6ac6d298f67)) + - **FEAT**: select decoder automatically on windows (#1221). ([ff78a42f](https://github.com/bluefireteam/audioplayers/commit/ff78a42f842e146df7dc98d6d00ae27821355653)) + + +## 2022-06-18 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v1.0.1`](#audioplayers---v101) + - [`audioplayers_android` - `v1.0.1`](#audioplayers_android---v101) + - [`audioplayers_darwin` - `v1.0.1`](#audioplayers_darwin---v101) + +--- + +#### `audioplayers` - `v1.0.1` + + - **FIX**: Make sure onComplete resets the position even when not looping (#1175). ([6e6005ac](https://github.com/bluefireteam/audioplayers/commit/6e6005ac98765aeeea62208b58a6cc6d0cb4b084)) + +#### `audioplayers_android` - `v1.0.1` + + - **FIX**: getDuration, getPosition causes MEDIA_ERROR_UNKNOWN (#1172). ([51b4c73e](https://github.com/bluefireteam/audioplayers/commit/51b4c73eaff5c60d1c3c3e42ae783df07d34be09)) + +#### `audioplayers_darwin` - `v1.0.1` + + - **FIX**: Make sure onComplete resets the position even when not looping (#1175). ([6e6005ac](https://github.com/bluefireteam/audioplayers/commit/6e6005ac98765aeeea62208b58a6cc6d0cb4b084)) + + +## 2022-06-12 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v1.0.0`](#audioplayers---v100) + - [`audioplayers_android` - `v1.0.0`](#audioplayers_android---v100) + - [`audioplayers_darwin` - `v1.0.0`](#audioplayers_darwin---v100) + - [`audioplayers_linux` - `v1.0.0`](#audioplayers_linux---v100) + - [`audioplayers_platform_interface` - `v1.0.0`](#audioplayers_platform_interface---v100) + - [`audioplayers_web` - `v1.0.0`](#audioplayers_web---v100) + - [`audioplayers_windows` - `v1.0.0`](#audioplayers_windows---v100) + +--- + +#### `audioplayers` - `v1.0.0` + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +#### `audioplayers_android` - `v1.0.0` + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +#### `audioplayers_darwin` - `v1.0.0` + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +#### `audioplayers_linux` - `v1.0.0` + + - **FIX**: missing onDuration event, free previous source when set url on Linux (#1129). ([b523a39e](https://github.com/bluefireteam/audioplayers/commit/b523a39e253dd461b07c360d7547eef9bb54cd65)) + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +#### `audioplayers_platform_interface` - `v1.0.0` + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +#### `audioplayers_web` - `v1.0.0` + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +#### `audioplayers_windows` - `v1.0.0` + + - **FIX**: Windows Failed to seekTo longer than 3:30s (#1125). ([8db4dcaa](https://github.com/bluefireteam/audioplayers/commit/8db4dcaa1446e1442c63134df80b95af852c078f)) + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + + +## 2022-05-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers_darwin` - `v1.0.0-rc.4`](#audioplayers_darwin---v100-rc4) + - [`audioplayers_web` - `v1.0.0-rc.3`](#audioplayers_web---v100-rc3) + - [`audioplayers` - `v1.0.0-rc.4`](#audioplayers---v100-rc4) + +Packages with dependency updates only: + +> Packages listed below depend on other packages in this workspace that have had changes. Their versions have been incremented to bump the minimum dependency versions of the packages they depend upon in this project. + + - `audioplayers` - `v1.0.0-rc.4` + +--- + +#### `audioplayers_darwin` - `v1.0.0-rc.4` + + - **FIX**: Fix iOS code that was missing from previous push (melos vs pub get issue) (#1122). ([fe737849](https://github.com/bluefireteam/audioplayers/commit/fe737849811d0de02cac56b73a613e4ceb78c218)) + +#### `audioplayers_web` - `v1.0.0-rc.3` + + - **FEAT**: Add onPlayerCompletion, onPlayerStateChanged and onDurationChanged for web (#1123). ([760e0c94](https://github.com/bluefireteam/audioplayers/commit/760e0c9443f4c63aadf4c5498767aeac6cd79346)) + + +## 2022-05-08 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers_linux` - `v1.0.0-rc.3`](#audioplayers_linux---v100-rc3) + - [`audioplayers` - `v1.0.0-rc.3`](#audioplayers---v100-rc3) + - [`audioplayers_darwin` - `v1.0.0-rc.3`](#audioplayers_darwin---v100-rc3) + - [`audioplayers_windows` - `v1.0.0-rc.3`](#audioplayers_windows---v100-rc3) + +--- + +#### `audioplayers_linux` - `v1.0.0-rc.3` + + - **FEAT**: Linux platform support (closes #798) (#1110). ([74616c54](https://github.com/bluefireteam/audioplayers/commit/74616c5471fb942d8f08c41de50c93d4387f8916)) + +#### `audioplayers` - `v1.0.0-rc.3` + + - **FIX**: Volume and rate can be set before audio playing on iOS (#1113). ([eca1dd0e](https://github.com/bluefireteam/audioplayers/commit/eca1dd0e85abd72dc6c17bd2b7a24912664b98a5)) + - **FEAT**: Linux platform support (closes #798) (#1110). ([74616c54](https://github.com/bluefireteam/audioplayers/commit/74616c5471fb942d8f08c41de50c93d4387f8916)) + +#### `audioplayers_darwin` - `v1.0.0-rc.3` + + - **FIX**: Volume and rate can be set before audio playing on iOS (#1113). ([eca1dd0e](https://github.com/bluefireteam/audioplayers/commit/eca1dd0e85abd72dc6c17bd2b7a24912664b98a5)) + +#### `audioplayers_windows` - `v1.0.0-rc.3` + + - **FEAT**: Linux platform support (closes #798) (#1110). ([74616c54](https://github.com/bluefireteam/audioplayers/commit/74616c5471fb942d8f08c41de50c93d4387f8916)) + + +## 2022-04-28 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v1.0.0-rc.2`](#audioplayers---v100-rc2) + - [`audioplayers_windows` - `v1.0.0-rc.2`](#audioplayers_windows---v100-rc2) + - [`audioplayers_darwin` - `v1.0.0-rc.2`](#audioplayers_darwin---v100-rc2) + - [`audioplayers_android` - `v1.0.0-rc.2`](#audioplayers_android---v100-rc2) + - [`audioplayers_platform_interface` - `v1.0.0-rc.2`](#audioplayers_platform_interface---v100-rc2) + - [`audioplayers_web` - `v1.0.0-rc.2`](#audioplayers_web---v100-rc2) + +--- + +#### `audioplayers` - `v1.0.0-rc.2` + + - Bump "audioplayers" to `1.0.0-rc.2`. + +#### `audioplayers_windows` - `v1.0.0-rc.2` + +#### `audioplayers_darwin` - `v1.0.0-rc.2` + +#### `audioplayers_android` - `v1.0.0-rc.2` + +#### `audioplayers_platform_interface` - `v1.0.0-rc.2` + +#### `audioplayers_web` - `v1.0.0-rc.2` + + +## 2022-04-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers` - `v1.0.0-rc.1`](#audioplayers---v100-rc1) + +--- + +#### `audioplayers` - `v1.0.0-rc.1` + + - First release after federation + + +## 2022-04-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers_windows` - `v1.0.0-rc.1`](#audioplayers_windows---v100-rc1) + - [`audioplayers_android` - `v1.0.0-rc.1`](#audioplayers_android---v100-rc1) + - [`audioplayers_darwin` - `v1.0.0-rc.1`](#audioplayers_darwin---v100-rc1) + +--- + +#### `audioplayers_windows` - `v1.0.0-rc.1` + + - First release after federation + +#### `audioplayers_android` - `v1.0.0-rc.1` + + - First release after federation + +#### `audioplayers_darwin` - `v1.0.0-rc.1` + + - First release after federation + + +## 2022-04-01 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers_web` - `v1.0.0-rc.1`](#audioplayers_web---v100-rc1) + +--- + +#### `audioplayers_web` - `v1.0.0-rc.1` + + - First release after federation + + +## 2022-03-31 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers_web` - `v1.0.0-rc.1`](#audioplayers_web---v100-rc1) + +--- + +#### `audioplayers_web` - `v1.0.0-rc.1` + + - First release after federation + + +## 2022-03-30 + +### Changes + +--- + +Packages with breaking changes: + + - There are no breaking changes in this release. + +Packages with other changes: + + - [`audioplayers_platform_interface` - `v1.0.0-rc.1`](#audioplayers_platform_interface---v100-rc1) + +--- + +#### `audioplayers_platform_interface` - `v1.0.0-rc.1` + + - First release after federation + diff --git a/LICENSE b/LICENSE index 382de332d..1a581b05c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2017 Luan Nico +Copyright (c) 2017 Blue Fire Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. \ No newline at end of file +SOFTWARE. diff --git a/README.md b/README.md deleted file mode 100644 index 8e1b49d6d..000000000 --- a/README.md +++ /dev/null @@ -1,321 +0,0 @@ -# AudioPlayers - -[![Pub](https://img.shields.io/pub/v/audioplayers.svg?style=popout)](https://pub.dartlang.org/packages/audioplayers) [![Build Status](https://github.com/luanpotter/audioplayers/workflows/build/badge.svg?branch=master)](https://github.com/luanpotter/audioplayers/actions?query=workflow%3A"build"+branch%3Amaster) [![Discord](https://img.shields.io/discord/509714518008528896.svg)](https://discord.gg/pxrBmy4) - -A Flutter plugin to play multiple simultaneously audio files, works for Android, iOS, macOS and web. - -![](/images/tab1s.jpg) ![](/images/tab2s.jpg) ![](/images/tab3s.jpg) - -## Contributing - -We now have new rules for contributing! - -All help is appreciated but if you have questions, bug reports, issues, feature requests, pull requests, etc, please first refer to our [Contributing Guide](contributing.md). - -Also, as always, please give us a star to help! - -## Support us - -You can support us by becoming a patron on Patreon, any support is much appreciated. - -[![Patreon](https://c5.patreon.com/external/logo/become_a_patron_button.png)](https://www.patreon.com/fireslime) - -## Feature Parity Table - -Not all features are available on all platforms. [Click here](feature_parity_table.md) to see a table relating what features can be used on each target. - -Feel free to use it for ideas for possible PRs and contributions you can help with on our roadmap! If you are submiting a PR, don't forget to update the table. - -## Usage - -An `AudioPlayer` instance can play a single audio at a time. To create it, simply call the constructor: - -```dart - AudioPlayer audioPlayer = AudioPlayer(); -``` - -To use the low latency API, better for gaming sounds, use: - -```dart - AudioPlayer audioPlayer = AudioPlayer(mode: PlayerMode.LOW_LATENCY); -``` - -In this mode the backend won't fire any duration or position updates. -Also, it is not possible to use the seek method to set the audio a specific position. -This mode is also not available on web. - -You can create multiple instances to play audio simultaneously. - -For all methods that return a `Future`: that's the status of the operation. If `1`, the operation was successful. Otherwise, it's the platform native error code. - -Logs are disable by default! To debug, run: - -```dart - AudioPlayer.logEnabled = true; -``` - -### Playing Audio - -There are four possible sources of audio: - -- Remote file on the Internet -- Local file on the user's device -- Local asset from your Flutter project -- Audio in the form of a byte array (in Flutter, Uint8List) - -Both for Remote Files or Local Files, use the `play` method, just setting appropriately the flag `isLocal`. - -For Local Assets, you have to use the `AudioCache` class (see below). - -To play a Remote File, just call `play` with the url (the `isLocal` parameter is false by default): - -If you want to play audio for a long period of time, you need to set appropriately the flag `stayAwake`, -If you pass `stayAwake` as true you need to add this permission to your app manifest: -``. - -```dart - play() async { - int result = await audioPlayer.play(url); - if (result == 1) { - // success - } - } -``` - -For a Local File, add the `isLocal` parameter: - -```dart - playLocal() async { - int result = await audioPlayer.play(localPath, isLocal: true); - } -``` - -To play a file in the form of a data buffer (Uint8List), use the method `playBytes`. -This currently only works for Android (requiring API >= 23, be sure to handle that if you use this method on your code). - -```dart - playLocal() async { - Uint8List byteData = .. // Load audio as a byte array here. - int result = await audioPlayer.playBytes(byteData); - } -``` - -The `isLocal` flag is required only because iOS and macOS make a difference about it (Android doesn't care either way). - -There is also an optional named `double volume` parameter, that defaults to `1.0`. It can go from `0.0` (mute) to `1.0` (max), varying linearly. - -The volume can also be changed at any time using the `setVolume` method. - -### Controlling - -Note: these features are not implemented in web yet. - -After playing, you can control the audio with pause, stop and seek commands. - -Pause will pause the audio but keep the cursor where it was. Subsequently calling play will resume from this point. - -```dart - int result = await audioPlayer.pause(); -``` - -Stop will stop the audio and reset the cursor. Subsequently calling play will resume from the beginning. - -```dart - int result = await audioPlayer.stop(); -``` - -Finally, use seek to jump through your audio: - -```dart - int result = await audioPlayer.seek(Duration(milliseconds: 1200)); -``` - -Also, you can resume (like play, but without new parameters): - -```dart - int result = await audioPlayer.resume(); -``` - -### Finer Control - -By default, the player will be release once the playback is finished or the stop method is called. - -This is because on Android, a MediaPlayer instance can be quite resource-heavy, and keep it unreleased would cause performance issues if you play lots of different audios. - -On iOS and macOS this doesn't apply, so release does nothing. - -You can change the Release Mode to determine the actual behavior of the MediaPlayer once finished/stopped. There are three options: - -- RELEASE: default mode, will release after stop/completed. -- STOP: will never release; calling play should be faster. -- LOOP: will never release; after completed, it will start playing again on loop. - -If you are not on RELEASE mode, you should call the release method yourself; for example: - -```dart - await audioPlayer.setUrl('clicking.mp3'); // prepare the player with this audio but do not start playing - await audioPlayer.setReleaseMode(ReleaseMode.STOP); // set release mode so that it never releases - - // on button click - await audioPlayer.resume(); // quickly plays the sound, will not release - - // on exiting screen - await audioPlayer.release(); // manually release when no longer needed -``` - -Despite the complex state diagram of Android's MediaPlayer, an AudioPlayer instance should never have an invalid state. Even if it's released, if resume is called, the data will be fetch again. - -#### Stream routing -You can choose between speakers and earpiece. By default using speakers. -Toggle between speakers and earpiece. -``` -int result = await player.earpieceOrSpeakersToggle(); -``` - -### Streams - -Note: streams are not available on web yet. - -The AudioPlayer supports subscribing to events like so: - -#### Duration Event - -This event returns the duration of the file, when it's available (it might take a while because it's being downloaded or buffered). - -```dart - player.onDurationChanged.listen((Duration d) { - print('Max duration: $d'); - setState(() => duration = d); - }); -``` - -#### Position Event - -This Event updates the current position of the audio. You can use it to make a progress bar, for instance. - -```dart - player.onAudioPositionChanged.listen((Duration p) => { - print('Current position: $p'); - setState(() => position = p); - }); -``` - -#### State Event - -This Event returns the current player state. You can use it to show if player playing, or stopped, or paused. - -```dart - player.onPlayerStateChanged.listen((PlayerState s) => { - print('Current player state: $s'); - setState(() => playerState = s); - }); -``` - -#### Completion Event - -This Event is called when the audio finishes playing; it's used in the loop method, for instance. - -It does not fire when you interrupt the audio with pause or stop. - -```dart - player.onPlayerCompletion.listen((event) { - onComplete(); - setState(() { - position = duration; - }); - }); -``` - -#### Error Event - -This is called when an unexpected error is thrown in the native code. - -```dart - player.onPlayerError.listen((msg) { - print('audioPlayer error : $msg'); - setState(() { - playerState = PlayerState.stopped; - duration = Duration(seconds: 0); - position = Duration(seconds: 0); - }); - }); -``` - -### AudioCache - -In order to play Local Assets, you must use the `AudioCache` class. AudioCache is not available for Flutter Web. - -Flutter does not provide an easy way to play audio on your assets, but this class helps a lot. It actually copies the asset to a temporary folder in the device, where it is then played as a Local File. - -It works as a cache because it keeps track of the copied files so that you can replay them without delay. - -You can find the full documentation for this class [here](https://github.com/luanpotter/audioplayers/blob/master/packages/audioplayers/doc/audio_cache.md). - -### playerId - -By default, each time you initialize a new instance of AudioPlayer a unique playerId is generated and assigned using [uuid package](https://pub.dev/packages/uuid), this is designed this way to play multiple audio files simultaneously, if you want to play using the same instance that was created before simply pass your playerId when creating a new AudioPlayer instance. - -```dart -final audioPlayer = AudioPlayer(playerId: 'my_unique_playerId'); -``` - -## Supported Formats - -You can check a list of supported formats below: - -- [Android](https://developer.android.com/guide/topics/media/media-formats.html) -- [iOS and macOS](https://www.techotopia.com/index.php/Playing_Audio_on_iOS_8_using_AVAudioPlayer#Supported_Audio_Formats) -- web: audio formats supported by the browser you are using ([more details](https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API)) - -## :warning: iOS & macOS App Transport Security - -By default iOS and macOS forbid loading from non-https url. To cancel this restriction on iOS or macOS you must edit your `.plist` and add: - -```xml -NSAppTransportSecurity - - NSAllowsArbitraryLoads - - -``` -## :warning: macOS Outgoing Connections - -By default, Flutter macOS apps don't allow outgoing connections, so playing audio files/streams from the internet won't work. To fix this, add the following to the `.entitlements` files for your app: - -```xml -com.apple.security.network.client - -``` - -**Note:** On Android by default, there is a restriction not allowing traffic from HTTP resources. There is a fix for this and it requires -adding `android:usesCleartextTraffic="true"` within your AndroidManifest.xml file located in `android/app/src/main/AndroidManifest.xml`. - -Here is an example of how it should look like: -```xml - - - - - ... - - -``` - -## Android Support - -Giving support to old Android devices is very hard, on this plugin we set the minSdk as 16, but we only ensure support >= 23 as that is the minimum version that the team has devices available to test changes and new features. - -This mean that, Audioplayer should work on older devices, but we can't give any guarantees, we will not be able to look after issues regarding API < 23. But we would glady take any pull requests from the community that fixes or improve support on those old versions. - -## Background playing - -To control playback from lock screen on iOS and Android you can use [audio_service](https://pub.dev/packages/audio_service). [Example](https://denis-korovitskii.medium.com/flutter-demo-audioplayers-on-background-via-audio-service-c95d65c90ae1) how to implement all AudioPlayers features with and audio_service. - -## Credits - -This was originally a fork of [rxlabz's audioplayer](https://github.com/rxlabz/audioplayer), but since we have diverged and added more features. - -Thanks for @rxlabz for the amazing work! diff --git a/README.md b/README.md new file mode 120000 index 000000000..b4ae14ea9 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +packages/audioplayers/README.md \ No newline at end of file diff --git a/contributing.md b/contributing.md index 1b4bea4a4..eb4aa6f37 100644 --- a/contributing.md +++ b/contributing.md @@ -4,17 +4,86 @@ Thanks for using audioplayers and especially for your interest in contributing t Please read this document carefully before doing anything else! +## Environment Setup + +Audioplayers is set up to run with the most recent `stable` version of Flutter, so make sure your version +matches that: + +```bash +flutter channel stable +``` + +Also, audioplayers uses [Melos](https://github.com/invertase/melos) to manage the project and dependencies. + +To install Melos, run the following command from your terminal: + +```bash +flutter pub global activate melos +``` + +Next, at the root of your locally cloned repository bootstrap the projects dependencies: + +```bash +melos bootstrap +``` + +The bootstrap command locally links all dependencies within the project without having to +provide manual [`dependency_overrides`](https://dart.dev/tools/pub/pubspec). +This allows all plugins, examples and tests to build from the local clone project. +You should only need to run this command once. + +> You do not need to run `flutter pub get` once bootstrap has been completed. + +### Current Development + +If you want to use the most recent changes in your own project add following dependencies to your `pubspec.yaml` or `pubspec_overrrides.yaml`: + +```yaml +dependency_overrides: + audioplayers: + git: + url: https://github.com/bluefireteam/audioplayers.git + path: 'packages/audioplayers' + audioplayers_platform_interface: + git: + url: https://github.com/bluefireteam/audioplayers.git + path: 'packages/audioplayers_platform_interface' + audioplayers_web: + git: + url: https://github.com/bluefireteam/audioplayers.git + path: 'packages/audioplayers_web' + audioplayers_linux: + git: + url: https://github.com/bluefireteam/audioplayers.git + path: 'packages/audioplayers_linux' + audioplayers_android: + git: + url: https://github.com/bluefireteam/audioplayers.git + path: 'packages/audioplayers_android' + audioplayers_darwin: + git: + url: https://github.com/bluefireteam/audioplayers.git + path: 'packages/audioplayers_darwin' + audioplayers_windows: + git: + url: https://github.com/bluefireteam/audioplayers.git + path: 'packages/audioplayers_windows' +``` + ## Old Issues/PRs -We have many existing open issues and a few open PRs that were created before this doc was created. We will try to respect their innocence of this file existence by doing our best effort to answer/address/fix/merge them as we normally would up to this point (i.e. as time permits). However, if an existing issue or PR is too blatant of an outlier from these rules, we reserve the right of asking, in the issue/PR for the author (or someone) to fix it so that it falls under the new rules (i.e. apply the templates, etc). If we need to do that, we will give two weeks for the issue/PR to be updated to follow the rules, otherwise it will be closed. +We have many existing open issues and a few open PRs that were created before this doc was created. We will try to respect their ignorance of this file's existence by doing our best effort to answer/address/fix/merge them as we normally would up to this point (i.e. as time permits). +However, if an existing issue or PR is too blatant of an outlier from these rules, we reserve the right of asking, in the issue/PR for the author (or someone) to fix it so that it falls under the new rules (i.e. apply the templates, etc). +If we need to do that, we will give two weeks for the issue/PR to be updated to follow the rules, otherwise it will be closed. Of course, anyone is free to open a similar followup at any time, as long as the new one follows the rules. -With that particular comment in mind, consider the following rules to apply to new issues only. +With that particular comment in mind, consider the following rules to apply to all new issues only. ## General Rules -This document is divided in sections for each kind of contribution you have, but for any of them, basically for any form of communication between members of the community, you must follow these rules. I am adding them here at the top because they apply to all sections but also because they are the uttermost important thing for us. +This document is divided in sections for each kind of contribution you have, but for any of them, basically for any form of communication between members of the community, you must follow these rules. +I am adding them here at the top because they apply to all sections but also because they are the uttermost important thing for us. * Read this doc, the readme and everything else required carefully * Use clear, correct and acceptable English @@ -31,7 +100,7 @@ After you read and accepted the rules above, you need to decide what kind of inq Questions are not bugs! Do not open issues for questions. Here are the channels to ask for help. -First of all, make sure you read *at least* the [Readme Document](README.md) in full. That is the basis of how this library work and its very well written with care and love by us. If you haven't read even the readme, don't expect us to answer a question that is already solved there. +First of all, make sure you read *at least* the [Readme Document](README.md) and the [Getting Started tutorial](getting_started.md) in full. That is the basis of how this library work and its very well written with care and love by us. If you haven't read even the basics, don't expect us to answer a question that is already solved there. Second, make sure you went through our FAQ, [Troubleshooting](troubleshooting.md). There are many questions we get asked all the time that we have put the time and effort to answer on that doc. So make sure your question is not already there. @@ -43,7 +112,7 @@ If you still have a question, then you might have a legit question! However issu * [Our discord channel](https://discord.gg/ny7eThk): This is [Fire Slime Games](https://fireslime.xyz/) discord server, the people that are also behind Flame/audioplayers. We have a channel on the server dedicated for audioplayers questions. There you will be able to find many people, often knowing much more than we do, eager to help you out (as long as you followed all the steps). This is the quicker way to get help! - * The `flame` tag on [Stack Overflow](https://stackoverflow.com/questions/tagged/flame): Since audioplayers is part of the flame project, feel free to use the [flame] tag on Stack Overflow to get people from the community to help. This might be a bit more involved than discord but if you make a properly acceptable Stack Overflow question, people will be much more willing to help you with hard problems. Also, you are leaving some documentation for future generations! + * The `flutter-audioplayers` tag on [Stack Overflow](https://stackoverflow.com/questions/tagged/flutter-audioplayers): Feel free to use the [flutter-audioplayers] tag on Stack Overflow to get people from the community to help. This might be a bit more involved than discord but if you make a properly acceptable Stack Overflow question, people will be much more willing to help you with hard problems. Also, you are leaving some documentation for future generations! ### Bugs / Issue Reports @@ -51,7 +120,11 @@ If you found a bug or issue, please report it to us! If you are unsure if it's a But the first step is, again, to search for an existing issue. Maybe your issue was already reported, and we don't want duplicates. In fact, if it was already reported, the existing issues might have tips and tricks to circumvent the issue until we fix it. -Once you are certain your bug is brand new, you can create an issue and select the `Bug Report` type. You **must** follow the template provided, read it carefully. +Also, if it's about a specific feature not being implemented on a specific platform, we already track that on the [Feature Parity Table](feature_parity_table.md). PRs are welcome, but no need to report what we already know. We are progressing under our best effort to fulfill the most requested gaps. + +Once you are certain your bug is legit and brand new, you can create an issue and select the `Bug Report` type. You **must** follow the template provided, read it carefully. + +**Note**: read the template and *replace* the sections with your content. Do not *keep* the instructions on the final text of your PR. PRs that contains copied excerpts from the template (other than the titles, etc), will be closed without notice. ### Feature Requests / PRs @@ -61,12 +134,16 @@ In order to open a Feature Request issue, just select the correct template under Once your feature got approved to start developing, feel free to send your PRs! However, we have a few important PR rules: + * Start your PR title with a [conventional commit](https://www.conventionalcommits.org) type (feat:, fix: etc). * Your build must pass. Please make sure everything is green! - * Follow guidelines. We don't have a code analyzer for the native side (yet!), but please follow the code around you to make it properly formatted and linted. For Java, please follow an acceptable standard [like this one](https://google.github.io/styleguide/javaguide.html). There is nothing worse than badly formatted code! - * Write clean, beautiful and easy to understand code, with comments if necessary and docs if possible. - * Update our README/docs accordingly to your change, making it clear which platforms are supported. + * Follow guidelines. For the Dart side, follow [Flame's official style guide](https://github.com/flame-engine/flame/blob/main/doc/development/style_guide.md). + We also provide code linting and formatting for the native side, where we take the [Flutter's formatting](https://github.com/flutter/packages/blob/main/script/tool/lib/src/format_command.dart) as reference: + * C/C++: [Chromium coding style](https://chromium.googlesource.com/chromium/src/+/refs/heads/main/styleguide/c++/c++.md) via [clang-format](https://clang.llvm.org/docs/ClangFormatStyleOptions.html), available for [CLion](https://www.jetbrains.com/help/clion/clangformat-as-alternative-formatter.html) and [VSCode](https://code.visualstudio.com/docs/cpp/cpp-ide#_code-formatting) + * Kotlin: [Kotlin style guide](https://developer.android.com/kotlin/style-guide) via [ktlint](https://github.com/pinterest/ktlint) and [EditorConfig](https://editorconfig.org/), available for [IntelliJ](https://www.jetbrains.com/help/idea/editorconfig.html) and [VSCode](https://marketplace.visualstudio.com/items?itemName=EditorConfig.EditorConfig) + * Swift: [Google Swift Style Guide](https://google.github.io/swift/) via [swift-format](https://github.com/apple/swift-format), available for [VSCode](https://marketplace.visualstudio.com/items?itemName=vknabel.vscode-apple-swift-format) or CLI with [native installation](https://github.com/apple/swift-format#getting-swift-format) or [Docker](https://github.com/mtgto/docker-swift-format/tree/main) + * Write clean, beautiful and easy to understand code, with comments if necessary and docs if applicable. + * Update our README/getting started/feature parity table/any other docs accordingly to your change, making it clear which platforms are supported. * Try to support all platforms where it makes sense. This is a hard thing to ask, and we understand and we will merge PRs that only work on one platform as well. But if you have the time, please help us with feature parity. - * Make sure your change is testable on the `example` app. If necessary, add it. This is **mandatory**. We need to be able to at least manually try your feature. Tests are even better of course (see below). - * Try to add tests. We (sadly) have very little test coverage. If any new feature had some tests, it would help us a great deal. But this is also a hard ask because we don't have the easiest infrastructure to test, in fact, audio is hard to test. - * Add your change to the CHANGELOG under the `[next]` section. - * Do not add a new version to the changelog, bump versions or anything like that. We will deal with the release process and decide the next version after things are merged. + * Make sure your change is testable on the `example` app. If necessary, add to it. This is **mandatory**. We need to be able to at least manually try your feature. Tests are even better of course (see below). + * Try to add tests, if possible. We don't strive for 100% coverage, but we have very basic driver tests and unit tests where it makes sense (not all places can be tested for an audio player app). + * Do not add a new version to the changelog, bump versions or anything like that. We will deal with the release process using `melos` whenever there is something to release. diff --git a/feature_parity_table.md b/feature_parity_table.md index 643e8f9c6..ea3e773a3 100644 --- a/feature_parity_table.md +++ b/feature_parity_table.md @@ -1,11 +1,39 @@ # Feature Parity Table -Not every feature is available on every platform yet. Use this table to keep track of our work and progress, and please help if you want :) +Not every feature is available on every platform yet. Use this table to keep track of our work and progress, and please help out if you want to. :) + +If you would like to assist us implement a missing feature, please browse the [issue tracker](https://github.com/bluefireteam/audioplayers/issues) and reach out to us on our [Discord](https://discord.gg/pxrBmy4) server so we can coordinate efforts. + +## Note on Android + +### Media3 ExoPlayer + +We are going to switch from the internal [Android MediaPlayer](https://developer.android.com/reference/android/media/MediaPlayer) to the recommended [Media3 ExoPlayer](https://developer.android.com/media/media3). +We still endorse the old media player until we are sure, the Media3 implementation fulfills all the needs. + +You already can try the Media3 implementation by adding `audioplayers_android_exo` to your apps `pubspec.yaml`. +This [overrides](https://docs.flutter.dev/packages-and-plugins/developing-packages#non-endorsed-federated-plugin) our endorsed Android plugin implementation `audioplayers_android`: + +```yaml +dependencies: + # ... + audioplayers: any + audioplayers_android_exo: any +``` + +For more, see the [audioplayers_android_exo](https://github.com/bluefireteam/audioplayers/blob/main/packages/audioplayers_android_exo/README.md) package. + +### Support for old SDKs + +Giving support to old Android devices is very hard, on this plugin we set the minSdk as 19, but we only ensure support >= 23 as that is the minimum version that the team has devices available to test changes and new features. + +This mean that, audioplayers should work on older devices, but we can't give any guarantees, we will not be able to look after issues regarding API < 23. But we would gladly take any pull requests from the community that fixes or improve support on those old versions. -Note: LLM means Low Latency Mode. ## Main Features +Note: LLM means Low Latency Mode. + @@ -13,63 +41,57 @@ Note: LLM means Low Latency Mode. + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Feature/PlatformiOS macOS webWindowsLinux
Audio Source
local file on deviceyesyesyesno
local assetyesyesyesyes
external URL fileyesyesyesyes
external URL streamyesyesyesyes
byte arraySDK >=23not yetnot yetnot yet
Audio Config
set urlyesyesyesyes
audio cache (pre-load)yesyesyesyes
low latency modeSDK >=21nonono
Audio Control Commands
resume / pause / stopyesyesyesyes
release / release modeyesyesyesnot yet
volumeyesyesyesyes
seekyesyesyesnot yet
Advanced Audio Control Commands
playback rateSDK >= 23yesyesyes
duck audioyes (except LLM)nonono
respect silenceyes (except LLM)yesnono
stay awakeyes (except LLM)yesnono
recording activenot yetyesnono
playing routeyes (except LLM)yesnono
Streams
duration eventyesyesyesnot yet
position eventyesyesyesyes
state eventyesyesyesnot yet
completion eventyesyesyesnot yet
error eventyesyesyesnot yet
Audio Source
local file on deviceyesyesyesnoyesyes
local assetyesyesyesyesyesyes
external URL fileyesyesyesyesyesyes
external URL streamyesyesyesyesyesyes
byte arraySDK >=23via conversionvia conversionvia conversionyesvia conversion
Audio Config
set urlyesyesyesyesyesyes
audio cache (pre-load)yesyesyesyesyesyes
low latency modeSDK >=21 (except `audioplayers_android_exo`) nonononono
Audio Control Commands
resume / pause / stopyesyesyesyesyesyes
releaseyesyesyesyesyesyes
loopyesyesyesyesyesyes
volumeyesyesyesyesyesyes
seekyesyesyesyesyesyes
Advanced Audio Control Commands
playback rateSDK >= 23yesyesyesyesyes
duck audioyes (except LLM)nonononono
respect silenceyes (except LLM)yesnononono
stay awakeyes (except LLM)yesnononono
recording activenot yetyesnononono
playing routeyes (except LLM)yesnononono
balanceyesnot yetnot yetyesyesyes
Streams
duration eventyesyesyesyesyesyes
position eventyesyesyesyesyesyes
state eventyesyesyesyesyesyes
completion eventyesyesyesyesyesyes
log eventyesyesyesyesyesyes

-## Notifications - -Apart from the main features for playing audio, some unrelated features to notification and lock screen management were added to audioplayers. - -This is not the best home for them though. We are working with @ryanheise to eventually extract the existing notification related code from audioplayers and either: - - * create a new package, audioplayers_notifications for it - * merge this code into the existing audio_service package - -audio_service is already a package that provides much more advanced notification/lock screen controls. Please follow this [example](https://denis-korovitskii.medium.com/flutter-demo-audioplayers-on-background-via-audio-service-c95d65c90ae1) to implement all AudioPlayers features with and audio_service. +## Lock Screen Controls -So please do not send any PRs or additions to the notifications/lock screen for now, unless it's part of our separation effort. +To control playback from lock screen on iOS and Android, you can use @ryanheise's excellent [audio_service](https://pub.dev/packages/audio_service) package. [This article](https://denis-korovitskii.medium.com/flutter-demo-audioplayers-on-background-via-audio-service-c95d65c90ae1) showcases how to integrate audioplayers features with and audio_service. -I will update this file as we move forward with this. +Do not send any PRs or additions regarding notifications/lock screen support, unless it is a generic change on our infrastructure/wiring to support better integrating with `audio_service`. -## Other Features +## Other Out-of-Scope Features -Some features are totally out of scope for the `audioplayers` package. The goal of this library is to provide a unified place to play audio media, be it songs, background musics, sound effects, etc, from different sources, and providing an array of advanced controls and listeners to control it via code. +Some features are also out of scope for the `audioplayers` package. The goal of this library is to provide a unified place to play audio media, be it songs, background musics, sound effects, etc, from different sources, and providing an array of advanced controls and listeners to control it via code. Non-goals: if the existing solutions proposed below are not good or do not work well with audioplayers, I am happy to collaborate to create an `audioplayers_x` separated package (eg `audioplayers_recorder`). - * notifications/locks screen: see section above, use this for now or audio_service; * interfaces: nothing related to interface building concerns audioplayers; you can use Flutter to build your interfaces; * audio recording: recording audio from the microphone into audio files and streams; there is already a package for this called [audio_recorder](https://github.com/ZaraclaJ/audio_recorder). * playlist: you can implement playlists as you wish by playing multiple audios or songs in sequence. Doesn't make sense for this package to have any builtin playlist mechanism. diff --git a/getting_started.md b/getting_started.md new file mode 100644 index 000000000..73cef6de1 --- /dev/null +++ b/getting_started.md @@ -0,0 +1,383 @@ +# Getting Started + +This tutorial should help you get started with the audioplayers library, covering the basics but guiding you all the way through advanced features. +You can also play around with our [official example app](https://bluefireteam.github.io/audioplayers/) and [explore the code](https://github.com/bluefireteam/audioplayers/tree/main/packages/audioplayers/example), that showcases every feature the library has to offer. + +In order to install this package, add the [latest version](pub.dev/packages/audioplayers) of `audioplayers` to your `pubspec.yaml` file. +This package uses [the Federated Plugin](https://docs.flutter.dev/development/packages-and-plugins/developing-packages) guidelines to support multiple platforms, so it should just work on all supported platforms your app is built for without any extra configuration. +You do not need to add the `audioplayers_*` packages directly. + +## Setup Platforms + +For building and running for certain platforms you need pay attention to additional steps: + +* [Linux Setup](packages/audioplayers_linux/README.md#setup-for-linux) (`audioplayers_linux`). +* [Windows Setup](packages/audioplayers_windows/README.md#setup-for-windows) (`audioplayers_windows`). + +## AudioPlayer + +An `AudioPlayer` instance can play a single audio at a time (think of it as a single boombox). To create it, simply call the constructor: + +```dart + final player = AudioPlayer(); +``` + +You can create as many instances as you wish to play multiple audios simultaneously, or just to more easily control separate sources. + +## Sources + +Each AudioPlayer is created empty and has to be configured with an audio source (and it can only have one; changing it will replace the previous source). + +The source (cf. packages/audioplayers/lib/src/source.dart) is basically what audio you are playing (a song, sound effect, radio stream, etc), and it can have one of 4 types: + +1. **UrlSource**: get the audio from a remote URL from the Internet. This can be a direct link to a supported file to be downloaded, or a radio stream. +1. **DeviceFileSource**: access a file in the user's device, probably selected by a file picker. +1. **AssetSource**: play an asset bundled with your app, by default within the `assets` directory. + To customize the prefix, see [AudioCache](#audiocache). +1. **BytesSource** (only some platforms): pass in the bytes of your audio directly (read it from anywhere). + +In order to set the source on your player instance, call `setSource` with the appropriate source object: + +```dart + await player.setSource(AssetSource('sounds/coin.wav')); +``` + +Alternatively, call the shortcut method: + +```dart + await player.setSourceUrl(url); // equivalent to setSource(UrlSource(url)); +``` + +Or, if you want to set the url and start playing, using the `play` shortcut: + +```dart + await player.play(DeviceFileSource(localFile)); // will immediately start playing +``` + +## Controls + +After the URL is set, you can use the following methods to control the player: + +### resume + +Starts playback from current position (by default, from the start). + +```dart + await player.resume(); +``` + +### seek + +Changes the current position (note: this does not affect the "playing" status). + +```dart + await player.seek(Duration(milliseconds: 1200)); +``` + +### pause + +Stops the playback but keeps the current position. + +```dart + await player.pause(); +``` + +### stop + +Stops the playback and also resets the current position. + +```dart + await player.stop(); +``` + +### release + +Equivalent to calling `stop` and then releasing of any resources associated with this player. + +This means that memory might be de-allocated, etc. + +Note that the player is also in a ready-to-use state; if you call `resume` again any necessary resources will be re-fetch. + +Particularly on Android, the media player is quite resource-intensive, and this will let it go. Data will be buffered again when needed (if it's a remote file, it will be downloaded again. + +### dispose + +Disposes the player. It is calling `release` and also closes all open streams. This player instance must not be used anymore! + +```dart + await player.dispose(); +``` + +### play + +Play is just a shortcut method that allows you to: + + * set a source + * configure some player parameters (volume) + * configure audio attributes + * resume (start playing immediately) + +All in a single function call. For most simple use cases, it might be the only method you need. + +## Player Parameters + +You can also change the following parameters: + +### Volume + +Changes the audio volume. Defaults to `1.0`. It can go from `0.0` (mute) to `1.0` (max; some platforms allow bigger than 1), varying linearly. + +```dart + await player.setVolume(0.5); +``` + +### Balance + +Changes stereo balance. Defaults to `0.0` (both channels). `1.0` - right channel only, `-1.0` - left channel only. + +```dart + await player.setBalance(1.0); // right channel only +``` + +### Playback Rate + +Changes the playback rate (i.e. the "speed" of playback). Defaults to `1.0` (normal speed). `2.0` would be 2x speed, etc. + +```dart + await player.setPlaybackRate(0.5); // half speed +``` + +### Release Mode + +The release mode is controlling what happens when the playback ends. There are 3 options: + +1. `.stop`: just stops the playback but keep all associated resources. +1. `.release` (default): releases all resources associated with this player, equivalent to calling the `release` method. +1. `.loop`: starts over after completion, looping over and over again. + +```dart + await player.setReleaseMode(ReleaseMode.loop); +``` + +**Note**: you can control exactly what happens when the playback ends using the `onPlayerComplete` stream (see Streams below). + +**Note**: there are caveats when looping audio without gaps. Depending on the file format and platform, when audioplayers uses the native implementation of the "looping" feature, there will be gaps between plays, which might not be noticeable for non-continuous SFX but will definitely be noticeable for looping songs. Please check out the Gapless Loop section on our [Troubleshooting Guide](https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md) for more details. + + +### Player Mode + +The Player Mode represents what kind of native SDK is used to playback audio, when multiple options are available (currently only relevant for Android). There are 2 options: + +1. `.mediaPlayer` (default): for long media files or streams. +1. `.lowLatency`: for short audio files, since it reduces the impacts on visuals or UI performance. + +**Note**: on low latency mode, these features are NOT available: +- get duration & duration event +- get position & position event +- playback completion event (this means you are responsible for stopping the player) +- seeking & seek completion event + +Normally you want to use `.mediaPlayer` unless you care about performance and your audios are short (i.e. for sound effects in games). + +## Logs + +You can globally control the amount of log messages that are emitted by this package: + +```dart + AudioLogger.logLevel = AudioLogLevel.info; +``` + +You can pick one of 3 options: + +1. `.info`: show any log messages, include info/debug messages +1. `.error` (default): show only error messages +1. `.none`: show no messages at all (not recommended) + +**Note**: before opening any issue, always try changing the log level to `.info` to gather any information that might assist you with solving the problem. + +**Note**: despite our best efforts, some native SDK implementations that we use spam a lot of log messages that we currently haven't figured out how to conform to this configuration (specially noticeable on Android). If you would like to contribute with a PR, they are more than welcome! + +You can also listen for [Log events](#Log event). + +## Audio Context + +An Audio Context is a (mostly mobile-specific) set of secondary, platform-specific aspects of audio playback, typically related to how the act of playing audio interacts with other features of the device. In most cases, you do not need to change this. + +The Audio Context configuration can be set globally for all players via: + +```dart + AudioPlayer.global.setAudioContext(AudioContextConfig(/*...*/).build()); +``` + +To configure a player specific Audio Context (if desired), use: + +```dart + player.setAudioContext(AudioContextConfig(/*...*/).build()); +``` + +**Note:** As the iOS platform can not handle contexts for each player individually, for convenience this would also set the Audio Context globally. + +While each platform has its own set of configurations, they are somewhat related, and you can create them using a unified interface call [`AudioContextConfig`](https://pub.dev/documentation/audioplayers/latest/audioplayers/AudioContextConfig-class.html). +It provides generic abstractions that convey intent, that are then converted to platform specific configurations. + +Note that if this process is not perfect, you can create your configuration from scratch by providing exact details for each platform via +[AudioContextAndroid](https://pub.dev/documentation/audioplayers_platform_interface/latest/audioplayers_platform_interface/AudioContextAndroid-class.html) and +[AudioContextIOS](https://pub.dev/documentation/audioplayers_platform_interface/latest/audioplayers_platform_interface/AudioContextIOS-class.html). + +```dart + player.setAudioContext(AudioContext( + android: AudioContextAndroid(/*...*/), + iOS: AudioContextIOS(/*...*/), + )); +``` + +## Streams + +Each player has a variety of streams that can be used to listen to events, state changes, and other useful information coming from the player. +All streams also emit the same native platform errors via the `onError` callback. + +#### Duration Event + +This event returns the duration of the file, when it's available (it might take a while because it's being downloaded or buffered). + +```dart + player.onDurationChanged.listen((Duration d) { + print('Max duration: $d'); + setState(() => duration = d); + }); +``` + +#### Position Event + +This Event updates the current position of the audio. You can use it to make a progress bar, for instance. + +```dart + player.onPositionChanged.listen((Duration p) => { + print('Current position: $p'); + setState(() => position = p); + }); +``` + +#### State Event + +This Event returns the current player state. You can use it to show if player playing, or stopped, or paused. + +```dart + player.onPlayerStateChanged.listen((PlayerState s) => { + print('Current player state: $s'); + setState(() => playerState = s); + }); +``` + +#### Completion Event + +This Event is called when the audio finishes playing; it's used in the loop method, for instance. + +It does not fire when you interrupt the audio with pause or stop. + +```dart + player.onPlayerComplete.listen((_) { + onComplete(); + setState(() { + position = duration; + }); + }); +``` + +### Log event + +This event returns the log messages from the native platform. +The logs are handled by default via `Logger.log()`, and errors via `Logger.error()`, see [Logs](#Logs). + +```dart + player.onLog.listen( + (String message) => Logger.log(message), + onError: (Object e, [StackTrace? stackTrace]) => Logger.error(e, stackTrace), + ); +``` + +Or to handle global logs: + +```dart + AudioPlayer.global.onLog.listen( + (String message) => Logger.log(message), + onError: (Object e, [StackTrace? stackTrace]) => Logger.error(e, stackTrace), + ); +``` + +### Event Stream + +All mentioned events can also be obtained by a combined event stream. + +```dart + player.eventStream.listen((AudioEvent event) { + print(event.eventType); + }); +``` + +Or to handle global events: + +```dart + AudioPlayer.global.eventStream.listen((GlobalAudioEvent event) { + print(event.eventType); + }); +``` + +## Advanced Concepts + +### AudioCache + +Flutter does not provide an easy way to play audio on your local assets, but that's where the `AudioCache` class comes into play. +It actually copies the asset to a temporary folder in the device, where it is then played as a Local File. +It works as a cache because it keeps track of the copied files so that you can replay them without delay. + +If desired, you can change the `AudioCache` per player via the `AudioPlayer().audioCache` property or for all players via `AudioCache.instance`. + +#### Local Assets + +When playing local assets, by default every instance of AudioPlayers uses a [shared global instance of AudioCache](https://pub.dev/documentation/audioplayers/latest/audioplayers/AudioPlayer/audioCache.html), that will have a [default prefix "/assets"](https://pub.dev/documentation/audioplayers/latest/audioplayers/AudioCache/prefix.html) configured, as per Flutter conventions. +However, you can easily change that by specifying your own instance of AudioCache with any other (or no) prefix. + +Default behavior, presuming that your audio is stored in `/assets/audio/my-audio.wav`: +```dart +final player = AudioPlayer(); +await player.play(AssetSource('audio/my-audio.wav')); +``` + +Remove the asset prefix for all players: +```dart +AudioCache.instance = AudioCache(prefix: '') +final player = AudioPlayer(); +await player.play(AssetSource('assets/audio/my-audio.wav')); +``` + +Set a different prefix for only one player (e.g. when using assets from another package): +```dart +final player = AudioPlayer(); +player.audioCache = AudioCache(prefix: 'packages/OTHER_PACKAGE/assets/') +await player.play(AssetSource('other-package-audio.wav')); +``` + +### playerId + +By default, each time you initialize a new instance of AudioPlayer, a unique playerId is generated and assigned to it using the [uuid package](https://pub.dev/packages/uuid). +This is used internally to route messages between multiple players, and it allows you to control multiple audios at the same time. +If you want to specify the playerId, you can do so when creating the playing: + +```dart + final player = AudioPlayer(playerId: 'my_unique_playerId'); +``` + +Two players with the same id will point to the same media player on the native side. + +### PositionUpdater + +By default, the position stream is updated on every new frame. You can change this behavior to e.g. update on a certain +interval with the `TimerPositionUpdater` or implement your own `PositionUpdater`: + +```dart + player.positionUpdater = TimerPositionUpdater( + interval: const Duration(milliseconds: 100), + getPosition: player.getCurrentPosition, + ); +``` diff --git a/images/icon_ap.svg b/images/icon_ap.svg new file mode 100644 index 000000000..28b2cf71b --- /dev/null +++ b/images/icon_ap.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + diff --git a/images/logo_ap_compact.svg b/images/logo_ap_compact.svg new file mode 100644 index 000000000..05e8413f1 --- /dev/null +++ b/images/logo_ap_compact.svg @@ -0,0 +1,65 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/logo_ap_horizontal.svg b/images/logo_ap_horizontal.svg new file mode 100644 index 000000000..fca068db0 --- /dev/null +++ b/images/logo_ap_horizontal.svg @@ -0,0 +1,75 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/images/screenshot_ctrl.png b/images/screenshot_ctrl.png new file mode 100644 index 000000000..18c1992be Binary files /dev/null and b/images/screenshot_ctrl.png differ diff --git a/images/screenshot_ctx.png b/images/screenshot_ctx.png new file mode 100644 index 000000000..4f98e195e Binary files /dev/null and b/images/screenshot_ctx.png differ diff --git a/images/screenshot_src.png b/images/screenshot_src.png new file mode 100644 index 000000000..f4569bed6 Binary files /dev/null and b/images/screenshot_src.png differ diff --git a/images/screenshot_stream.png b/images/screenshot_stream.png new file mode 100644 index 000000000..c02dec705 Binary files /dev/null and b/images/screenshot_stream.png differ diff --git a/images/tab1.jpg b/images/tab1.jpg deleted file mode 100644 index fadef9d3b..000000000 Binary files a/images/tab1.jpg and /dev/null differ diff --git a/images/tab1s.jpg b/images/tab1s.jpg deleted file mode 100644 index d415c568b..000000000 Binary files a/images/tab1s.jpg and /dev/null differ diff --git a/images/tab2.jpg b/images/tab2.jpg deleted file mode 100644 index 4fe8037a6..000000000 Binary files a/images/tab2.jpg and /dev/null differ diff --git a/images/tab2s.jpg b/images/tab2s.jpg deleted file mode 100644 index 4cb97918c..000000000 Binary files a/images/tab2s.jpg and /dev/null differ diff --git a/images/tab3.jpg b/images/tab3.jpg deleted file mode 100644 index 83ce17765..000000000 Binary files a/images/tab3.jpg and /dev/null differ diff --git a/images/tab3s.jpg b/images/tab3s.jpg deleted file mode 100644 index 5215aefff..000000000 Binary files a/images/tab3s.jpg and /dev/null differ diff --git a/migration_guide.md b/migration_guide.md new file mode 100644 index 000000000..c8d29b272 --- /dev/null +++ b/migration_guide.md @@ -0,0 +1,71 @@ +# Migration Guide + +## Migrate from v1 to higher versions + +We recommend following the migration instructions for the breaking changes in the [changelog](CHANGELOG.md). + +## Migrate from v0 to v1 + +Despite several major infrastructural changes in v1.0.0, it should be very easy to migrate for end users, as things overall just got simpler. + +This document contains the list of major changes to be aware of. + +First step is to add the latest version (see [pub.dev](https://pub.dev/packages/audioplayers) for the latest version) to your `pubspec.yaml`: + +```yaml + dependencies: + audioplayers: ^2.0.0 +``` + +### Federation, simplified platform interface + +This change is here as an introduction but should require no change whatsoever on users side. But we split the package using the official [Federation](https://docs.flutter.dev/development/packages-and-plugins/developing-packages) process from Flutter. You still only need to import the final package `audioplayers` into your project, but know that that will fetch the relevant implementations for each platform you support as `audioplayers_x` packages. + +In order to support this, we also created a vastly simplified `audioplayers_platform_interface` that is allowing us to add support for other platforms (eg desktop) much easier but removing any shortcuts or duplicated methods and leaving everything that can, to be implemented on the Dart side. Platforms have to deal with only the most basic building blocks they have to implement, and nothing else. + +### AudioCache is dead, long live Sources + +One of the main changes was my desire to "kill" the AudioCache API due to the vast confusion that it caused with users (despite our best efforts documenting everything). + +We still have the AudioCache class but its APIs are _exclusively_ dedicated to transforming asset files into local files, cache them, and provide the path. It however doesn't normally need be used by end users because the AudioPlayer itself is now capable of playing audio from any Source. + +What is a Source? It's a sealed class that can be one of: + +1. **UrlSource**: get the audio from a remote URL from the Internet +1. **DeviceFileSource**: access a file in the user's device, probably selected by a file picker +1. **AssetSource**: play an asset bundled with your app, normally within the `assets` directory +1. **BytesSource** (only some platforms): pass in the bytes of your audio directly (read it from anywhere). + +If you use **AssetSource**, the AudioPlayer will use its instance of AudioCache (which defaults to the global cache if unchanged) automatically. This unifies all playing APIs under AudioPlayer and entirely removes the AudioCache detail for most users. + +### Simplified APIs, one method per task + +We removed multiple overrides and used the concept of Sources to unify methods under AP. Now we have base, separated methods for: + +1. `setSource` (taking a Source object; we also provide `setSourceX` as shortcuts) +1. `setVolume` +1. `setAudioContext` (though consider using `AudioPlayer.global.setGlobalAudioContext` instead) +1. `resume` (actually starts playing) + +We still have (other than the handy `setSourceX` methods) one shortcut left: the `play` method. I think it's important to keep that as it might be easiest way for the most simple operation; it does: + +1. set the source via a Source object +1. optionally sets the volume +1. optionally sets the audio context +1. optionally sets the position to seek +1. optionally sets the player mode +1. resumes (starts playing) + +All in one go. We might decide whether to keep this shortcut or what parameters exactly to have on a next refactor. But for now we are very happy that we no longer have `play` and `playBytes` being essentially clones with different sources on `AudioPlayer` and then `AudioCache` having its own versions + looping versions (it was chaotic before). + +### Enum name consolidation, some files were shuffled around + +As per Dart's new best practices, all enums on the Dart side now have lowercase constants. + +Also, some files might have been shuffled around (even between packages), but nothing that your IDE won't be able to quickly sort out. + +### AudioContext + +For some people, this will be irrelevant. For others, this might be the biggest change. Basically we collected all the random flags and parameters that were related to audio context/session configuration spread through the codebase on different methods, at different stages, into a single, unified configuration object called AudioContext, that can be set globally or per player (only for Android). + +For more details, check the Audio Context section on the [Getting Started tutorial](getting_started.md), or the [class documentation itself](https://github.com/bluefireteam/audioplayers/blob/main/packages/audioplayers_platform_interface/lib/api/audio_context_config.dart) (which is very comprehensive). diff --git a/packages/audioplayers/CHANGELOG.md b/packages/audioplayers/CHANGELOG.md index 97ca08bda..62e3cf729 100644 --- a/packages/audioplayers/CHANGELOG.md +++ b/packages/audioplayers/CHANGELOG.md @@ -1,3 +1,213 @@ +## 6.5.1 + + - **FIX**: Initialize audioplayer instances sequentially ([#1941](https://github.com/bluefireteam/audioplayers/issues/1941)). ([663fff2c](https://github.com/bluefireteam/audioplayers/commit/663fff2cb8482c81cb525c9d97bfb7f5d02dfdee)) + +## 6.5.0 + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + - **FEAT**: Customizable preparation and seeking timeout ([#1921](https://github.com/bluefireteam/audioplayers/issues/1921)). ([d5a63f8b](https://github.com/bluefireteam/audioplayers/commit/d5a63f8b206554f6a6719653c5ce9b92b8d096b9)) + - **FEAT**: Support for Swift Package Manager ([#1908](https://github.com/bluefireteam/audioplayers/issues/1908)). ([e8f86e7b](https://github.com/bluefireteam/audioplayers/commit/e8f86e7bf80ddb8b0955d35c53f08cbf5f2d141b)) + +## 6.4.0 + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +## 6.3.0 + + - **FEAT**(android): ExoPlayer for Android ([#1691](https://github.com/bluefireteam/audioplayers/issues/1691)). ([a91c5b18](https://github.com/bluefireteam/audioplayers/commit/a91c5b185054986a2390d41593b5ee502ef96bdd))\ + You can enable the non-endorsed plugin implementation based on ExoPlayer (Media3) by calling: `flutter pub add audioplayers_android_exo` in your app package folder. + For more, see: https://github.com/bluefireteam/audioplayers/blob/main/feature_parity_table.md#media3-exoplayer + - **FEAT**: Raise to Flutter 3.29.0 ([#1891](https://github.com/bluefireteam/audioplayers/issues/1891)). ([26bd6d22](https://github.com/bluefireteam/audioplayers/commit/26bd6d228deacf7cb3454f2d1b972585cd8bb6ea)) + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +## 6.2.0 + + - **FIX**: No-op on single player setAudioContext in desktop platforms ([#1888](https://github.com/bluefireteam/audioplayers/issues/1888)). ([50d7a8b8](https://github.com/bluefireteam/audioplayers/commit/50d7a8b89f47e3ef29e98cf2b74a582f78783d5e)) + - **FEAT**: Support setting AudioContext in AudioPool ([#1890](https://github.com/bluefireteam/audioplayers/issues/1890)). ([2968c88b](https://github.com/bluefireteam/audioplayers/commit/2968c88b4e1492a29d4cd0e5f7735f159f995c1a)) + - **FEAT**: ReleaseMode.release for ios, macos, windows, web, linux ([#1790](https://github.com/bluefireteam/audioplayers/issues/1790)). ([4ffc4029](https://github.com/bluefireteam/audioplayers/commit/4ffc4029d846d7c391c457b829c372c1763b7b50)) + +## 6.1.2 + + - Update a dependency to the latest release. + +## 6.1.1 + + - Update a dependency to the latest release. + +## 6.1.0 + + - **FEAT**: Upgrade to Flutter v3.22.x ([#1803](https://github.com/bluefireteam/audioplayers/issues/1803)). ([4d669e72](https://github.com/bluefireteam/audioplayers/commit/4d669e723dc5c2399073301ba9333f99bc623669)) + +## 6.0.0 + +> Note: This release has breaking changes. + + - **FIX**: Use unique tmp location for each AudioCache ([#1724](https://github.com/bluefireteam/audioplayers/issues/1724)). ([2333cb7f](https://github.com/bluefireteam/audioplayers/commit/2333cb7f5a9fcd84bdd477120d1f53f346c3b10d)) + - **FIX**: Race condition when playing/pausing audio ([#1705](https://github.com/bluefireteam/audioplayers/issues/1705)). ([463b2a11](https://github.com/bluefireteam/audioplayers/commit/463b2a1149105a25f81d708533d13cc2dd277d6b)) + - **FIX**: Seek not applied in `play` method ([#1695](https://github.com/bluefireteam/audioplayers/issues/1695)). ([f6138fef](https://github.com/bluefireteam/audioplayers/commit/f6138fef97ccd5b78b44dbe85f7d41e16b3662f6)) + - **FIX**: Propagate Stream Errors through the same Future ([#1732](https://github.com/bluefireteam/audioplayers/issues/1732)). ([00d041df](https://github.com/bluefireteam/audioplayers/commit/00d041df11c26fd96f480782f2787f857c77daa0)) + - **FIX**: Wait for seek to complete ([#1712](https://github.com/bluefireteam/audioplayers/issues/1712)). ([fd33b1d0](https://github.com/bluefireteam/audioplayers/commit/fd33b1d073280797cdd88fb6324cc1906bfd5957)) + - **FEAT**: Support byte array and data URIs via mimeType ([#1763](https://github.com/bluefireteam/audioplayers/issues/1763)). ([eaf7ce86](https://github.com/bluefireteam/audioplayers/commit/eaf7ce86ad271097365fcf9e3a03fc341629ae47)) + - **FEAT**(ios): Improved AudioContextConfig assertions, fix example ([#1619](https://github.com/bluefireteam/audioplayers/issues/1619)). ([df342c52](https://github.com/bluefireteam/audioplayers/commit/df342c529b0b13abd0515c5dc762987293ebc4c1)) + - **FEAT**(web): Support compilation to Wasm ([#1766](https://github.com/bluefireteam/audioplayers/issues/1766)). ([1b1a0cf9](https://github.com/bluefireteam/audioplayers/commit/1b1a0cf92e950bc520598426d3f073c3bd5a6a28)) + - **DOCS**: Improve Docs ([#1710](https://github.com/bluefireteam/audioplayers/issues/1710)). ([4208463a](https://github.com/bluefireteam/audioplayers/commit/4208463a4110ed117eebe28e170872817712ff53)) + - **BREAKING** **REFACTOR**: Remove deprecated methods ([#1583](https://github.com/bluefireteam/audioplayers/issues/1583)). ([8d0cbeda](https://github.com/bluefireteam/audioplayers/commit/8d0cbeda6babea69b1753340f9cec3d246d7e29a)) + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **FEAT**: Extend `AudioContextConfig.duckAudio` to `AudioContextConfig.focus` ([#1720](https://github.com/bluefireteam/audioplayers/issues/1720)). ([87f3cb7e](https://github.com/bluefireteam/audioplayers/commit/87f3cb7e47e2103d2079a3dfe6aebe80c8a76c3d)) + - **BREAKING** **FEAT**(ios): Improve AudioContextIOS ([#1591](https://github.com/bluefireteam/audioplayers/issues/1591)). ([25fbec05](https://github.com/bluefireteam/audioplayers/commit/25fbec051a4f521f73c473cdad20f88c7907d7b1)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + - **BREAKING** **CHORE**: Upgrade to Flutter 3.13.0 ([#1612](https://github.com/bluefireteam/audioplayers/issues/1612)). ([1a3de1ac](https://github.com/bluefireteam/audioplayers/commit/1a3de1acd5a8b90b6d9c0d0f2a7141723c277c24)) + +## 5.2.1 + + - **FIX**: Avoid decoding already encoded character in URI ([#1679](https://github.com/bluefireteam/audioplayers/issues/1679)). ([1923205c](https://github.com/bluefireteam/audioplayers/commit/1923205c4cde70e2915e6e6c6afeb2fec27a08e8)) + - **FIX**(android): Released wrong source in LOW_LATENCY mode ([#1672](https://github.com/bluefireteam/audioplayers/issues/1672)). ([d9c5f693](https://github.com/bluefireteam/audioplayers/commit/d9c5f693cafab21b67b785de6244c3c371344a53)) + +## 5.2.0 + + - **REFACTOR**: Lint Swift ([#1613](https://github.com/bluefireteam/audioplayers/issues/1613)). ([737aa94f](https://github.com/bluefireteam/audioplayers/commit/737aa94f7edb076d622c34e498b90f17c9959e9c)) + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Cancel `onPreparedSubscription` on error ([#1660](https://github.com/bluefireteam/audioplayers/issues/1660)). ([c11dbf30](https://github.com/bluefireteam/audioplayers/commit/c11dbf3094457799a3b89fd6f0b386799b2f943c)) + - **FIX**: Set playback rate only when playing ([#1658](https://github.com/bluefireteam/audioplayers/issues/1658)). ([d73c7d5c](https://github.com/bluefireteam/audioplayers/commit/d73c7d5c2ef13e8eff2c438b96ade6e2483a2014)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + - **FEAT**(windows): Support for BytesSource on Windows ([#1601](https://github.com/bluefireteam/audioplayers/issues/1601)). ([a9e14710](https://github.com/bluefireteam/audioplayers/commit/a9e147107aa31072d4bcc69a02b2ee287d4b366b)) + - **FEAT**: Allow adding custom media sources to example ([#1637](https://github.com/bluefireteam/audioplayers/issues/1637)). ([1eabe619](https://github.com/bluefireteam/audioplayers/commit/1eabe61957caf969f132ce6fad7b99208887466b)) + - **DOCS**: Deploy live example app to GH pages ([#1623](https://github.com/bluefireteam/audioplayers/issues/1623)). ([fe81f3b1](https://github.com/bluefireteam/audioplayers/commit/fe81f3b1e600fe005febbe7cd3da02735a3de004)) + +## 5.1.0 + + - **REFACTOR**(darwin): Rearrange code ([#1585](https://github.com/bluefireteam/audioplayers/issues/1585)). ([13639d1f](https://github.com/bluefireteam/audioplayers/commit/13639d1f2fe5afbc17f4e862e2da0f7551b8fc3e)) + - **FEAT**: Get current volume, balance and playbackRate ([#1582](https://github.com/bluefireteam/audioplayers/issues/1582)). ([0c2ff7b1](https://github.com/bluefireteam/audioplayers/commit/0c2ff7b1289238150388e571396ac92b28a8ea5d)) + +## 5.0.0 + +> Note: This release has breaking changes. + + - **REFACTOR**(windows): simplify position and duration processing ([#1553](https://github.com/bluefireteam/audioplayers/issues/1553)). ([ca63c5a4](https://github.com/bluefireteam/audioplayers/commit/ca63c5a4b120e0d1ea421e6ab30f590c314a33f2)) + - **FIX**(example): Use kotlin version compatible with AGP8 ([#1577](https://github.com/bluefireteam/audioplayers/issues/1577)). ([8f4b1bb0](https://github.com/bluefireteam/audioplayers/commit/8f4b1bb0bc93df095bff2a4d4c2f92a4c4a85d17)) + - **FIX**(linux): allow reusing event channel with same name ([#1555](https://github.com/bluefireteam/audioplayers/issues/1555)). ([5471189f](https://github.com/bluefireteam/audioplayers/commit/5471189f9469e973f9262a120b02b321ca0dce24)) + - **FEAT**(android): Add support for AGP 8 in example, add compileOptions to build.gradle ([#1503](https://github.com/bluefireteam/audioplayers/issues/1503)). ([7c08e4e1](https://github.com/bluefireteam/audioplayers/commit/7c08e4e1a524f53294f6967996fd31837e62cb81)) + - **BREAKING** **FIX**: Default audio output to system preferences ([#1563](https://github.com/bluefireteam/audioplayers/issues/1563)). ([381c43e3](https://github.com/bluefireteam/audioplayers/commit/381c43e3725fbb0cb4fd35982893a3c92b188886)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +## 4.1.0 + + - **REFACTOR**: Adapt to flame_lint v0.2.0+2 ([#1477](https://github.com/bluefireteam/audioplayers/issues/1477)). ([e1d7fb6a](https://github.com/bluefireteam/audioplayers/commit/e1d7fb6ab57c8a523c80dfc673bde3b7379b2add)) + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + - **FEAT**: Adapt position update interval of darwin, linux, web ([#1492](https://github.com/bluefireteam/audioplayers/issues/1492)). ([ab5bdf6a](https://github.com/bluefireteam/audioplayers/commit/ab5bdf6a2bcbf7e984d4d897e43a67b3684c52d8)) + - **DOCS**: Improve docs ([#1518](https://github.com/bluefireteam/audioplayers/issues/1518)). ([4c0d5546](https://github.com/bluefireteam/audioplayers/commit/4c0d55465a8e75c13987b970dee648657eba4384)) + +## 4.0.1 + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +## 4.0.0 + +> Note: This release has breaking changes. + + - **FIX**(android): Avoid calling onDuration on position event (closes [#136](https://github.com/bluefireteam/audioplayers/issues/136)) ([#1460](https://github.com/bluefireteam/audioplayers/issues/1460)). ([6cfb3753](https://github.com/bluefireteam/audioplayers/commit/6cfb3753cd8003f341d97e0b2417d4512f452267)) + - **FEAT**: replace `Platform.isX` with `defaultTargetPlatform` ([#1446](https://github.com/bluefireteam/audioplayers/issues/1446)). ([6cd5656c](https://github.com/bluefireteam/audioplayers/commit/6cd5656c0c5deaab1fb4af78a5b7632402c3a1d3)) + - **FEAT**(example): add invalid asset, small refactor, colored source buttons ([#1445](https://github.com/bluefireteam/audioplayers/issues/1445)). ([92a20fad](https://github.com/bluefireteam/audioplayers/commit/92a20fadd6f549d44b7055b38a48fad2861a05c8)) + - **FEAT**(android): add `setBalance` ([#58](https://github.com/bluefireteam/audioplayers/issues/58)) ([#1444](https://github.com/bluefireteam/audioplayers/issues/1444)). ([3b5de50e](https://github.com/bluefireteam/audioplayers/commit/3b5de50ea7fa5248165616fc1ffd80da6c66583a)) + - **FEAT**: extract AudioContext from audio_context_config ([#1440](https://github.com/bluefireteam/audioplayers/issues/1440)). ([e59c3b9f](https://github.com/bluefireteam/audioplayers/commit/e59c3b9f07c1a72f9bf4e424fa3b011645f191d2)) + - **FEAT**(ios): set player context globally on `setAudioContext` for iOS only ([#1416](https://github.com/bluefireteam/audioplayers/issues/1416)). ([19af364b](https://github.com/bluefireteam/audioplayers/commit/19af364b7d0404ae436c54cdaa18d50f3a2aacd6)) + - **FEAT**(example): update app icons ([#1417](https://github.com/bluefireteam/audioplayers/issues/1417)). ([ac35df89](https://github.com/bluefireteam/audioplayers/commit/ac35df895cefe3d69dac4c8b1cf07c7f7ed56ca7)) + - **FEAT**: AudioPool (moved and improved from flame_audio) ([#1403](https://github.com/bluefireteam/audioplayers/issues/1403)). ([ab15cb02](https://github.com/bluefireteam/audioplayers/commit/ab15cb02cf939347772ac9fc961b5f01d7bad94b)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **DOCS**: update example app and screenshots ([#1419](https://github.com/bluefireteam/audioplayers/issues/1419)). ([c48eaf38](https://github.com/bluefireteam/audioplayers/commit/c48eaf389ab5b1cf1d51fadc814f473b8ea813cb)) + - **BREAKING** **REFACTOR**: prevent from confusing and conflicting class names ([#1465](https://github.com/bluefireteam/audioplayers/issues/1465)). ([7cdb8586](https://github.com/bluefireteam/audioplayers/commit/7cdb858605f24f0abd1a225e04922830233f3e96)) + - **BREAKING** **REFACTOR**: improve separation of global audioplayer interface ([#1443](https://github.com/bluefireteam/audioplayers/issues/1443)). ([c0b3f85c](https://github.com/bluefireteam/audioplayers/commit/c0b3f85c477f0313299cc2a2898840d6c7d8dcd9)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + - **BREAKING** **FEAT**: expose classes of package `audioplayers_platform_interface` ([#1442](https://github.com/bluefireteam/audioplayers/issues/1442)). ([a6f89be1](https://github.com/bluefireteam/audioplayers/commit/a6f89be181b7bd664eaf96cb9509bbc5adf5dbb9)) + +### Migration instructions + +| Before | After | +|---|---| +| deprecated `AudioPlayer.global.changeLogLevel(LogLevel.info)` | `AudioLogger.logLevel = AudioLogLevel.info` | +| deprecated `AudioPlayer.global.logLevel` | `AudioLogger.logLevel` | +| deprecated `AudioPlayer.global.log()` | `AudioLogger.log()` or `AudioLogger.error()` | +| deprecated `AudioPlayer.global.info()` | `AudioLogger.log()` | +| deprecated `AudioPlayer.global.error()` | `AudioLogger.error()` | +| `GlobalPlatformInterface` | `GlobalAudioScope` | +| deprecated `AudioPlayer.global.setGlobalAudioContext()` | `AudioPlayer.global.setAudioContext()` | +| `ForPlayer<>` | _removed_ | + +## 3.0.1 + + - Update a dependency to the latest release. + +## 3.0.0 + +> Note: This release has breaking changes. + + - **FEAT**: add and remove player actions ([#1394](https://github.com/bluefireteam/audioplayers/issues/1394)). ([f06cab91](https://github.com/bluefireteam/audioplayers/commit/f06cab91fbae65d7fdc9e3fbd75171b391ac0b96)) + - **FEAT**: example improvements ([#1392](https://github.com/bluefireteam/audioplayers/issues/1392)). ([002e2fc9](https://github.com/bluefireteam/audioplayers/commit/002e2fc950145e3231ab79a5ef399024d62f6fb1)) + - **BREAKING** **REFACTOR**: rename logger_platform_interface.dart to global_platform_interface.dart ([#1385](https://github.com/bluefireteam/audioplayers/issues/1385)). ([6e837c1c](https://github.com/bluefireteam/audioplayers/commit/6e837c1ccd93b95d10843a403674128cf303c0ab)) + - **BREAKING** **FEAT**: configurable SoundPool and `AudioManager.mode` ([#1388](https://github.com/bluefireteam/audioplayers/issues/1388)). ([5697f187](https://github.com/bluefireteam/audioplayers/commit/5697f187bcca64de2e519f8f49aaf4817fcf6398)) + +## 2.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **FIX**: remove unused `defaultToSpeaker` in `AudioContextIOS` and replace with `AVAudioSessionOptions.defaultToSpeaker` ([#1374](https://github.com/bluefireteam/audioplayers/issues/1374)). ([d844ef9d](https://github.com/bluefireteam/audioplayers/commit/d844ef9def06fd5047076d9f4c371ad3be4c8dd5)) + +## 1.2.0 + +> Note: This release has breaking changes. + + - **FIX**: Duration precision on Windows ([#1342](https://github.com/bluefireteam/audioplayers/issues/1342)). ([3cda1a65](https://github.com/bluefireteam/audioplayers/commit/3cda1a65dc0425c332ed2eb3619cd88531f0ea49)) + - **FIX**: infinity / nan on getDuration ([#1298](https://github.com/bluefireteam/audioplayers/issues/1298)). ([a4474dcf](https://github.com/bluefireteam/audioplayers/commit/a4474dcf5e14fbd74db8b4f19223b9bfa40ed5f5)) + - **FEAT**: upgrade flutter to v3.0.0 and dart 2.17 to support "Super initializers" ([#1355](https://github.com/bluefireteam/audioplayers/issues/1355)). ([4af417b4](https://github.com/bluefireteam/audioplayers/commit/4af417b4c91ed5c22d6c48e05080c3018ccaee42)) + - **FEAT**: local test server ([#1354](https://github.com/bluefireteam/audioplayers/issues/1354)). ([06be429a](https://github.com/bluefireteam/audioplayers/commit/06be429a0078456a989b9afc3abc68164c4abaab)) + - **FEAT**: get current source ([#1350](https://github.com/bluefireteam/audioplayers/issues/1350)). ([7a10be38](https://github.com/bluefireteam/audioplayers/commit/7a10be38ec6613c8ef45bb33d1e81f11bb5988f9)) + - **FEAT**: log path and url of sources ([#1334](https://github.com/bluefireteam/audioplayers/issues/1334)). ([8a13f96d](https://github.com/bluefireteam/audioplayers/commit/8a13f96dbb14be0d1d80577816246109c42b7983)) + - **FEAT**: add setBalance ([#58](https://github.com/bluefireteam/audioplayers/issues/58)) ([#1282](https://github.com/bluefireteam/audioplayers/issues/1282)). ([782fc9df](https://github.com/bluefireteam/audioplayers/commit/782fc9dff24a2ab9681496fd7c4c8fed451eac35)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + - **BREAKING** **FIX**: Cache should take key to be properly cleared ([#1347](https://github.com/bluefireteam/audioplayers/issues/1347)). ([1a410bba](https://github.com/bluefireteam/audioplayers/commit/1a410bba578af506637b026bb2c4ace03a161a69)) + +## 1.1.1 + + - **FIX**: infinity / nan on getDuration ([#1298](https://github.com/bluefireteam/audioplayers/issues/1298)). ([a4474dcf](https://github.com/bluefireteam/audioplayers/commit/a4474dcf5e14fbd74db8b4f19223b9bfa40ed5f5)) + +## 1.1.0 + + - **FIX**: player state not being updated to completed (#1257). ([70a37afb](https://github.com/bluefireteam/audioplayers/commit/70a37afb6ce4fbb8b8c680ca9b6804b005012446)) + - **FIX**: lowLatency bugs (closes #1176, closes #1193, closes #1165) (#1272). ([541578cc](https://github.com/bluefireteam/audioplayers/commit/541578cc50f3856c23c393faa1a71380b3b49222)) + - **FIX**: ios/macos no longer start audio when calling only setSourceUrl (#1206). ([c0e97f04](https://github.com/bluefireteam/audioplayers/commit/c0e97f04fb05fb109830d6363f5c44dccbd327b4)) + - **FEAT**: improve example (#1267). ([a8154da1](https://github.com/bluefireteam/audioplayers/commit/a8154da1cc6fdec80d80fa538d65cb491a33db78)) + - **FEAT**: Platform integration tests 🤖 (#1128). ([b0c84aab](https://github.com/bluefireteam/audioplayers/commit/b0c84aabea8af28f693941c1b3bf2b1fa1048833)) + - **DOCS**: Remove 11-month old outdated doc file (#1180). ([bae43cb1](https://github.com/bluefireteam/audioplayers/commit/bae43cb10a27eff23ebaf2a6ac796fd61039f359)) + +## 1.0.1 + + - **FIX**: Make sure onComplete resets the position even when not looping (#1175). ([6e6005ac](https://github.com/bluefireteam/audioplayers/commit/6e6005ac98765aeeea62208b58a6cc6d0cb4b084)) + +## 1.0.0 + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +## 1.0.0-rc.4 + + - Update a dependency to the latest release. + +## 1.0.0-rc.3 + + - **FIX**: Volume and rate can be set before audio playing on iOS (#1113). ([eca1dd0e](https://github.com/bluefireteam/audioplayers/commit/eca1dd0e85abd72dc6c17bd2b7a24912664b98a5)) + - **FEAT**: Linux platform support (closes #798) (#1110). ([74616c54](https://github.com/bluefireteam/audioplayers/commit/74616c5471fb942d8f08c41de50c93d4387f8916)) + +## 1.0.0-rc.2 + + - Bump "audioplayers" to `1.0.0-rc.2`. + +## 1.0.0-rc.1 + + - First release after federation + # Changelog ## 0.20.2 diff --git a/packages/audioplayers/LICENSE b/packages/audioplayers/LICENSE deleted file mode 120000 index 30cff7403..000000000 --- a/packages/audioplayers/LICENSE +++ /dev/null @@ -1 +0,0 @@ -../../LICENSE \ No newline at end of file diff --git a/packages/audioplayers/LICENSE b/packages/audioplayers/LICENSE new file mode 100644 index 000000000..1a581b05c --- /dev/null +++ b/packages/audioplayers/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audioplayers/README.md b/packages/audioplayers/README.md deleted file mode 120000 index fe8400541..000000000 --- a/packages/audioplayers/README.md +++ /dev/null @@ -1 +0,0 @@ -../../README.md \ No newline at end of file diff --git a/packages/audioplayers/README.md b/packages/audioplayers/README.md new file mode 100644 index 000000000..5442eb511 --- /dev/null +++ b/packages/audioplayers/README.md @@ -0,0 +1,105 @@ +

+ + AudioPlayers + +

+

+ A Flutter plugin to play multiple simultaneously audio files, works for Android, iOS, Linux, macOS, Windows, and web. +

+ +

+ + + + +

+ +--- + + +

Check out the live example app.

+ +**Note**: all the docs are kept up to date to reflect the content of the current newest release. If you are looking for older information and guidance, please checkout the [tag](https://github.com/bluefireteam/audioplayers/tags) related to the version that you are looking for. + + +If you are interest in migrating major versions, please check the [changelog](CHANGELOG.md) and [our migration guide](https://github.com/bluefireteam/audioplayers/blob/main/migration_guide.md). + +## Getting Started + +We tried to make audioplayers as simple to use as possible: + +```dart +import 'package:audioplayers/audioplayers.dart'; +// ... +final player = AudioPlayer(); +await player.play(UrlSource('https://example.com/my-audio.wav')); +``` + +Please follow our [Getting Started tutorial](https://github.com/bluefireteam/audioplayers/blob/main/getting_started.md) for all high-level information you need to know. + +Then, if you want to dig deeper, our code is very well documented with dartdocs, so check [our API reference](https://pub.dev/documentation/audioplayers/latest/) or the codebase itself on your IDE (or on GitHub). + +If something is not clear on our docs, please send a PR to help us improve. + +## Help + +If you have any problems, please follow these steps before opening an issue. + +1. Carefully read the [Getting Started tutorial](https://github.com/bluefireteam/audioplayers/blob/main/getting_started.md) before anything else. Re-read if necessary. +1. Check our [Troubleshooting Guide](https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md) for solutions for most problems. +1. If you have a missing feature report or feature request, please first check the [Feature Parity Table](https://github.com/bluefireteam/audioplayers/blob/main/feature_parity_table.md) to understand our roadmap and what we know is missing. We love contributions! +1. Join [Blue Fire's Discord server](https://discord.gg/5unKpdQD78) and ask for directions. Maybe it's not a bug, or it's a known issue. +1. If you are more comfortable with StackOverflow, you can also create a question there. Add the [flutter-audioplayers tag](https://stackoverflow.com/questions/tagged/flutter-audioplayers), so that anyone following the tag can help out. +1. If the issue still persists, go to the [create an issue](https://github.com/bluefireteam/audioplayers/issues/new/choose) page and follow the step-by-step there before submitting. +1. If the step-by-step there doesn't give you any help, then proceed to create the issue **following the template**. Do not skip mandatory sections. Do not include the literal text of the template, rather replace the sections with what they should contain. + +Any issues created not following the list above can be flagged or closed by our team. + +## Feature Parity Table + +Not all features are available on all platforms. [Click here](https://github.com/bluefireteam/audioplayers/blob/main/feature_parity_table.md) to see a table relating what features can be used on each target. + +Feel free to use it for ideas for possible PRs and contributions you can help with on our roadmap! If you are submitting a PR, don't forget to update the table. + +## Support + +The simplest way to show us your support is by giving the project a star! :star: + +You can also support us monetarily by donating through OpenCollective: + + + + + +Through GitHub Sponsors: + + + + + +Or by becoming a patron on Patreon: + + + + + +**Note**: this software was made by the community, for the community, on our spare time, with no commercial affiliation. +It is provided as is and any positive contribution is appreciated. +Be kind and mindful of the free time that a battalion of people has gifted on behalf of the community to craft and maintain this. + +## Contributing + +All help is appreciated but if you have questions, bug reports, issues, feature requests, pull requests, etc, please first refer to our [Contributing Guide](https://github.com/bluefireteam/audioplayers/blob/main/contributing.md). + +Be sure to check the [Feature Parity Table](https://github.com/bluefireteam/audioplayers/blob/main/feature_parity_table.md) to understand if your suggestion is already tracked, on the roadmap, or out of scope for this project. + +Also, as always, please give us a star to help! + +## Credits + +This was originally a fork of [rxlabz's audioplayer](https://github.com/rxlabz/audioplayer), but since we have diverged and added more features. + +Thanks for @rxlabz for the amazing work! diff --git a/packages/audioplayers/android/settings.gradle b/packages/audioplayers/android/settings.gradle deleted file mode 100644 index 5f577d58c..000000000 --- a/packages/audioplayers/android/settings.gradle +++ /dev/null @@ -1 +0,0 @@ -rootProject.name = 'audioplayers' diff --git a/packages/audioplayers/android/src/main/AndroidManifest.xml b/packages/audioplayers/android/src/main/AndroidManifest.xml deleted file mode 100644 index 9f4796ee8..000000000 --- a/packages/audioplayers/android/src/main/AndroidManifest.xml +++ /dev/null @@ -1,4 +0,0 @@ - \ No newline at end of file diff --git a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt b/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt deleted file mode 100644 index f8bd09a39..000000000 --- a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt +++ /dev/null @@ -1,250 +0,0 @@ -package xyz.luan.audioplayers - -import android.content.Context -import android.os.Handler -import io.flutter.embedding.engine.plugins.FlutterPlugin -import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding -import io.flutter.plugin.common.MethodCall -import io.flutter.plugin.common.MethodChannel -import io.flutter.plugin.common.MethodChannel.MethodCallHandler -import java.lang.ref.WeakReference - -class AudioplayersPlugin : MethodCallHandler, FlutterPlugin { - private lateinit var channel: MethodChannel - private lateinit var context: Context - - private val mediaPlayers = mutableMapOf() - private val handler = Handler() - private var positionUpdates: Runnable? = null - - private var seekFinish = false - - override fun onAttachedToEngine(binding: FlutterPluginBinding) { - channel = MethodChannel(binding.binaryMessenger, "xyz.luan/audioplayers") - context = binding.applicationContext - seekFinish = false - channel.setMethodCallHandler(this) - } - - override fun onDetachedFromEngine(binding: FlutterPluginBinding) {} - override fun onMethodCall(call: MethodCall, response: MethodChannel.Result) { - try { - handleMethodCall(call, response) - } catch (e: Exception) { - Logger.error("Unexpected error!", e) - response.error("Unexpected error!", e.message, e) - } - } - - private fun handleMethodCall(call: MethodCall, response: MethodChannel.Result) { - when (call.method) { - "changeLogLevel" -> { - val value = call.enumArgument("value") - ?: throw error("value is required") - Logger.logLevel = value - response.success(1) - return - } - } - val playerId = call.argument("playerId") ?: return - val mode = call.argument("mode") - val player = getPlayer(playerId, mode) - when (call.method) { - "play" -> { - configureAttributesAndVolume(call, player) - - val url = call.argument("url")!! - val isLocal = call.argument("isLocal") ?: false - player.setUrl(url, isLocal) - - val position = call.argument("position") - if (position != null && mode != "PlayerMode.LOW_LATENCY") { - player.seek(position) - } - player.play() - } - "playBytes" -> { - configureAttributesAndVolume(call, player) - - val bytes = call.argument("bytes") ?: throw error("bytes are required") - player.setDataSource(ByteDataSource(bytes)) - - val position = call.argument("position") - if (position != null && mode != "PlayerMode.LOW_LATENCY") { - player.seek(position) - } - player.play() - } - "resume" -> player.play() - "pause" -> player.pause() - "stop" -> player.stop() - "release" -> player.release() - "seek" -> { - val position = call.argument("position") ?: throw error("position is required") - player.seek(position) - } - "setVolume" -> { - val volume = call.argument("volume") ?: throw error("volume is required") - player.setVolume(volume) - } - "setUrl" -> { - val url = call.argument("url") !! - val isLocal = call.argument("isLocal") ?: false - player.setUrl(url, isLocal) - } - "setPlaybackRate" -> { - val rate = call.argument("playbackRate") ?: throw error("playbackRate is required") - player.setRate(rate) - } - "getDuration" -> { - response.success(player.getDuration() ?: 0) - return - } - "getCurrentPosition" -> { - response.success(player.getCurrentPosition() ?: 0) - return - } - "setReleaseMode" -> { - val releaseMode = call.enumArgument("releaseMode") - ?: throw error("releaseMode is required") - player.setReleaseMode(releaseMode) - } - "earpieceOrSpeakersToggle" -> { - val playingRoute = call.argument("playingRoute") ?: throw error("playingRoute is required") - player.setPlayingRoute(playingRoute) - } - else -> { - response.notImplemented() - return - } - } - response.success(1) - } - - private fun configureAttributesAndVolume( - call: MethodCall, - player: Player - ) { - val respectSilence = call.argument("respectSilence") ?: false - val stayAwake = call.argument("stayAwake") ?: false - val duckAudio = call.argument("duckAudio") ?: false - player.configAttributes(respectSilence, stayAwake, duckAudio) - - val volume = call.argument("volume") ?: 1.0 - player.setVolume(volume) - } - - private fun getPlayer(playerId: String, mode: String?): Player { - return mediaPlayers.getOrPut(playerId) { - if (mode.equals("PlayerMode.MEDIA_PLAYER", ignoreCase = true)) { - WrappedMediaPlayer(this, playerId) - } else { - WrappedSoundPool(playerId) - } - } - } - - fun getApplicationContext(): Context { - return context.applicationContext - } - - fun handleIsPlaying() { - startPositionUpdates() - } - - fun handleDuration(player: Player) { - channel.invokeMethod("audio.onDuration", buildArguments(player.playerId, player.getDuration() ?: 0)) - } - - fun handleCompletion(player: Player) { - channel.invokeMethod("audio.onComplete", buildArguments(player.playerId, true)) - } - - fun handleError(player: Player, message: String) { - channel.invokeMethod("audio.onError", buildArguments(player.playerId, message)) - } - - fun handleSeekComplete() { - seekFinish = true - } - - private fun startPositionUpdates() { - if (positionUpdates != null) { - return - } - positionUpdates = UpdateCallback(mediaPlayers, channel, handler, this).also { - handler.post(it) - } - } - - private fun stopPositionUpdates() { - positionUpdates = null - handler.removeCallbacksAndMessages(null) - } - - private class UpdateCallback( - mediaPlayers: Map, - channel: MethodChannel, - handler: Handler, - audioplayersPlugin: AudioplayersPlugin - ) : Runnable { - private val mediaPlayers = WeakReference(mediaPlayers) - private val channel = WeakReference(channel) - private val handler = WeakReference(handler) - private val audioplayersPlugin = WeakReference(audioplayersPlugin) - - override fun run() { - val mediaPlayers = mediaPlayers.get() - val channel = channel.get() - val handler = handler.get() - val audioplayersPlugin = audioplayersPlugin.get() - if (mediaPlayers == null || channel == null || handler == null || audioplayersPlugin == null) { - audioplayersPlugin?.stopPositionUpdates() - return - } - var nonePlaying = true - for (player in mediaPlayers.values) { - if (!player.isActuallyPlaying()) { - continue - } - try { - nonePlaying = false - val key = player.playerId - val duration = player.getDuration() - val time = player.getCurrentPosition() - channel.invokeMethod("audio.onDuration", buildArguments(key, duration ?: 0)) - channel.invokeMethod("audio.onCurrentPosition", buildArguments(key, time ?: 0)) - if (audioplayersPlugin.seekFinish) { - channel.invokeMethod("audio.onSeekComplete", buildArguments(player.playerId, true)) - audioplayersPlugin.seekFinish = false - } - } catch (e: UnsupportedOperationException) { - } - } - if (nonePlaying) { - audioplayersPlugin.stopPositionUpdates() - } else { - handler.postDelayed(this, 200) - } - } - - } - - companion object { - private fun buildArguments(playerId: String, value: Any): Map { - return mapOf( - "playerId" to playerId, - "value" to value - ) - } - - private fun error(message: String): Exception { - return IllegalArgumentException(message) - } - } -} - -private inline fun > MethodCall.enumArgument(name: String): T? { - val enumName = argument(name) ?: return null - return enumValueOf(enumName.split('.').last()) -} diff --git a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/Logger.kt b/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/Logger.kt deleted file mode 100644 index ef4b65b18..000000000 --- a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/Logger.kt +++ /dev/null @@ -1,32 +0,0 @@ -package xyz.luan.audioplayers - -import android.util.Log - -enum class LogLevel(val value: Int) { - INFO(2), ERROR(1), NONE(0) -} - -object Logger { - var logLevel: LogLevel = LogLevel.ERROR - var androidLogger: (String, String, Throwable?) -> Unit = { tag, message, t -> - Log.d(tag, message, t) - } - - fun info(message: String) { - log(LogLevel.INFO, message) - } - - fun error(message: String) { - log(LogLevel.ERROR, message) - } - - fun error(message: String, throwable: Throwable) { - log(LogLevel.ERROR, message, throwable) - } - - private fun log(level: LogLevel, message: String, throwable: Throwable? = null) { - if (level.value <= logLevel.value) { - androidLogger("AudioPlayers", message, throwable) - } - } -} \ No newline at end of file diff --git a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/Player.kt b/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/Player.kt deleted file mode 100644 index 55702715d..000000000 --- a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/Player.kt +++ /dev/null @@ -1,36 +0,0 @@ -package xyz.luan.audioplayers - -import android.media.MediaDataSource - -abstract class Player { - abstract val playerId: String - - abstract fun getDuration(): Int? - abstract fun getCurrentPosition(): Int? - abstract fun isActuallyPlaying(): Boolean - - abstract fun play() - abstract fun stop() - abstract fun release() - abstract fun pause() - - abstract fun configAttributes(respectSilence: Boolean, stayAwake: Boolean, duckAudio: Boolean) - abstract fun setUrl(url: String, isLocal: Boolean) - abstract fun setDataSource(mediaDataSource: MediaDataSource?) - abstract fun setVolume(volume: Double) - abstract fun setRate(rate: Double) - abstract fun setReleaseMode(releaseMode: ReleaseMode) - abstract fun setPlayingRoute(playingRoute: String) - - /** - * Seek operations cannot be called until after the player is ready. - */ - abstract fun seek(position: Int) - - companion object { - @JvmStatic - protected fun objectEquals(o1: Any?, o2: Any?): Boolean { - return o1 == null && o2 == null || o1 != null && o1 == o2 - } - } -} \ No newline at end of file diff --git a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/WrappedMediaPlayer.kt b/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/WrappedMediaPlayer.kt deleted file mode 100644 index a0ff0dc5f..000000000 --- a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/WrappedMediaPlayer.kt +++ /dev/null @@ -1,380 +0,0 @@ -package xyz.luan.audioplayers - -import android.content.Context -import android.media.* -import android.os.Build -import android.os.PowerManager - -class WrappedMediaPlayer internal constructor( - private val ref: AudioplayersPlugin, - override val playerId: String -) : Player(), MediaPlayer.OnPreparedListener, MediaPlayer.OnCompletionListener, AudioManager.OnAudioFocusChangeListener, MediaPlayer.OnSeekCompleteListener, MediaPlayer.OnErrorListener { - private val audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null - private var audioFocusRequest: AudioFocusRequest? = null - - private var player: MediaPlayer? = null - private var url: String? = null - private var dataSource: MediaDataSource? = null - private var volume = 1.0 - private var rate = 1.0f - private var respectSilence = false - private var stayAwake = false - private var duckAudio = false - private var releaseMode: ReleaseMode = ReleaseMode.RELEASE - private var playingRoute: String = "speakers" - private var released = true - private var prepared = false - private var playing = false - private var shouldSeekTo = -1 - - /** - * Setter methods - */ - override fun setUrl(url: String, isLocal: Boolean) { - if (this.url != url) { - this.url = url - val player = getOrCreatePlayer() - player.setDataSource(url) - preparePlayer(player) - } - - if (Build.VERSION.SDK_INT >= 23) { - // Dispose of any old data buffer array, if we are now playing from another source. - dataSource = null - } - } - - override fun setDataSource(mediaDataSource: MediaDataSource?) { - if (Build.VERSION.SDK_INT >= 23) { - if (!objectEquals(dataSource, mediaDataSource)) { - dataSource = mediaDataSource - val player = getOrCreatePlayer() - player.setDataSource(mediaDataSource) - preparePlayer(player) - } - } else { - throw RuntimeException("setDataSource is only available on API >= 23") - } - } - - private fun preparePlayer(player: MediaPlayer) { - player.setVolume(volume.toFloat(), volume.toFloat()) - player.isLooping = releaseMode === ReleaseMode.LOOP - player.prepareAsync() - } - - private fun getOrCreatePlayer(): MediaPlayer { - val currentPlayer = player - return if (released || currentPlayer == null) { - createPlayer().also { - player = it - released = false - } - } else if (prepared) { - currentPlayer.also { - it.reset() - prepared = false - } - } else { - currentPlayer - } - } - - override fun setVolume(volume: Double) { - if (this.volume != volume) { - this.volume = volume - if (!released) { - player?.setVolume(volume.toFloat(), volume.toFloat()) - } - } - } - - override fun setPlayingRoute(playingRoute: String) { - if (this.playingRoute != playingRoute) { - val wasPlaying = playing - if (wasPlaying) { - pause() - } - this.playingRoute = playingRoute - val position = player?.currentPosition ?: 0 - released = false - player = createPlayer().also { - it.setDataSource(url) - it.prepare() - - seek(position) - if (wasPlaying) { - playing = true - it.start() - } - } - } - } - - override fun setRate(rate: Double) { - this.rate = rate.toFloat() - - val player = this.player ?: return - if (Build.VERSION.SDK_INT >= 23) { - player.playbackParams = player.playbackParams.setSpeed(this.rate) - } - } - - override fun configAttributes(respectSilence: Boolean, stayAwake: Boolean, duckAudio: Boolean) { - if (this.respectSilence != respectSilence) { - this.respectSilence = respectSilence - if (!released) { - player?.let { setAttributes(it) } - } - } - if (this.duckAudio != duckAudio) { - this.duckAudio = duckAudio - if (!released) { - player?.let { setAttributes(it) } - } - } - if (this.stayAwake != stayAwake) { - this.stayAwake = stayAwake - if (!released && this.stayAwake) { - player?.setWakeMode(ref.getApplicationContext(), PowerManager.PARTIAL_WAKE_LOCK) - } - } - } - - override fun onAudioFocusChange(focusChange: Int) { - if (focusChange == AudioManager.AUDIOFOCUS_GAIN) { - actuallyPlay() - } - } - - override fun setReleaseMode(releaseMode: ReleaseMode) { - if (this.releaseMode !== releaseMode) { - this.releaseMode = releaseMode - if (!released) { - player?.isLooping = releaseMode === ReleaseMode.LOOP - } - } - } - - /** - * Getter methods - */ - override fun getDuration(): Int? { - return player?.duration - } - - override fun getCurrentPosition(): Int? { - return player?.currentPosition - } - - override fun isActuallyPlaying(): Boolean { - return playing && prepared - } - - private val audioManager: AudioManager - get() = ref.getApplicationContext().getSystemService(Context.AUDIO_SERVICE) as AudioManager - - /** - * Playback handling methods - */ - override fun play() { - if (duckAudio) { - val audioManager = audioManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val audioFocusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(if (respectSilence) AudioAttributes.USAGE_NOTIFICATION_RINGTONE else AudioAttributes.USAGE_MEDIA) - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .build() - ) - .setOnAudioFocusChangeListener { actuallyPlay() }.build() - this.audioFocusRequest = audioFocusRequest - audioManager.requestAudioFocus(audioFocusRequest) - } else { - // Request audio focus for playback - @Suppress("DEPRECATION") - val result = audioManager.requestAudioFocus(audioFocusChangeListener, // Use the music stream. - AudioManager.STREAM_MUSIC, // Request permanent focus. - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK) - if (result == AudioManager.AUDIOFOCUS_REQUEST_GRANTED) { - actuallyPlay() - } - } - } else { - actuallyPlay() - } - } - - private fun actuallyPlay() { - if (!playing) { - val currentPlayer = player - playing = true - if (released || currentPlayer == null) { - released = false - player = createPlayer().also { - if (Build.VERSION.SDK_INT >= 23 && dataSource != null) { - it.setDataSource(dataSource) - } else { - it.setDataSource(url) - } - it.prepareAsync() - } - } else if (prepared) { - currentPlayer.start() - ref.handleIsPlaying() - } - } - } - - override fun stop() { - if (duckAudio) { - val audioManager = audioManager - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } - } else { - @Suppress("DEPRECATION") - audioManager.abandonAudioFocus(audioFocusChangeListener) - } - } - if (released) { - return - } - if (releaseMode !== ReleaseMode.RELEASE) { - if (playing) { - playing = false - player?.pause() - player?.seekTo(0) - } - } else { - release() - } - } - - override fun release() { - if (released) { - return - } - if (playing) { - player?.stop() - } - player?.reset() - player?.release() - player = null - prepared = false - released = true - playing = false - } - - override fun pause() { - if (playing) { - playing = false - player?.pause() - } - } - - // seek operations cannot be called until after - // the player is ready. - override fun seek(position: Int) { - if (prepared) { - player?.seekTo(position) - } else { - shouldSeekTo = position - } - } - - /** - * MediaPlayer callbacks - */ - override fun onPrepared(mediaPlayer: MediaPlayer) { - prepared = true - ref.handleDuration(this) - if (playing) { - player?.start() - ref.handleIsPlaying() - } - if (shouldSeekTo >= 0) { - player?.seekTo(shouldSeekTo) - shouldSeekTo = -1 - } - } - - override fun onCompletion(mediaPlayer: MediaPlayer) { - if (releaseMode !== ReleaseMode.LOOP) { - stop() - } - ref.handleCompletion(this) - } - - override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { - var whatMsg: String - whatMsg = if (what == MediaPlayer.MEDIA_ERROR_SERVER_DIED) { - "MEDIA_ERROR_SERVER_DIED" - } else { - "MEDIA_ERROR_UNKNOWN {what:$what}" - } - val extraMsg: String - when (extra) { - -2147483648 -> extraMsg = "MEDIA_ERROR_SYSTEM" - MediaPlayer.MEDIA_ERROR_IO -> extraMsg = "MEDIA_ERROR_IO" - MediaPlayer.MEDIA_ERROR_MALFORMED -> extraMsg = "MEDIA_ERROR_MALFORMED" - MediaPlayer.MEDIA_ERROR_UNSUPPORTED -> extraMsg = "MEDIA_ERROR_UNSUPPORTED" - MediaPlayer.MEDIA_ERROR_TIMED_OUT -> extraMsg = "MEDIA_ERROR_TIMED_OUT" - else -> { - whatMsg = "MEDIA_ERROR_UNKNOWN {extra:$extra}" - extraMsg = whatMsg - } - } - ref.handleError(this, "MediaPlayer error with what:$whatMsg extra:$extraMsg") - return false - } - - override fun onSeekComplete(mediaPlayer: MediaPlayer) { - ref.handleSeekComplete() - } - - /** - * Internal logic. Private methods - */ - private fun createPlayer(): MediaPlayer { - val player = MediaPlayer() - player.setOnPreparedListener(this) - player.setOnCompletionListener(this) - player.setOnSeekCompleteListener(this) - player.setOnErrorListener(this) - - setAttributes(player) - player.setVolume(volume.toFloat(), volume.toFloat()) - player.isLooping = releaseMode === ReleaseMode.LOOP - return player - } - - private fun setAttributes(player: MediaPlayer) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - val usage = when { - // Works with bluetooth headphones - // automatically switch to earpiece when disconnect bluetooth headphones - playingRoute != "speakers" -> AudioAttributes.USAGE_VOICE_COMMUNICATION - respectSilence -> AudioAttributes.USAGE_NOTIFICATION_RINGTONE - else -> AudioAttributes.USAGE_MEDIA - } - player.setAudioAttributes( - AudioAttributes.Builder() - .setUsage(usage) - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .build() - ) - if (usage == AudioAttributes.USAGE_VOICE_COMMUNICATION) { - audioManager.isSpeakerphoneOn = false - } - } else { - // This method is deprecated but must be used on older devices - if (playingRoute == "speakers") { - player.setAudioStreamType(if (respectSilence) AudioManager.STREAM_RING else AudioManager.STREAM_MUSIC) - } else { - player.setAudioStreamType(AudioManager.STREAM_VOICE_CALL) - } - } - } - -} diff --git a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/WrappedSoundPool.kt b/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/WrappedSoundPool.kt deleted file mode 100644 index 5ebe81bf2..000000000 --- a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/WrappedSoundPool.kt +++ /dev/null @@ -1,255 +0,0 @@ -package xyz.luan.audioplayers - -import android.media.AudioAttributes -import android.media.AudioManager -import android.media.MediaDataSource -import android.media.SoundPool -import java.io.ByteArrayOutputStream -import java.io.File -import java.io.FileOutputStream -import java.net.URI -import java.net.URL -import java.util.* -import android.os.Build - -class WrappedSoundPool internal constructor(override val playerId: String) : Player() { - companion object { - private val soundPool = createSoundPool() - - /** For the onLoadComplete listener, track which sound id is associated with which player. An entry only exists until - * it has been loaded. - */ - private val soundIdToPlayer = Collections.synchronizedMap(mutableMapOf()) - - /** This is to keep track of the players which share the same sound id, referenced by url. When a player release()s, it - * is removed from the associated player list. The last player to be removed actually unloads() the sound id and then - * the url is removed from this map. - */ - private val urlToPlayers = Collections.synchronizedMap(mutableMapOf>()) - - private fun createSoundPool(): SoundPool { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - val attrs = AudioAttributes.Builder().setLegacyStreamType(AudioManager.USE_DEFAULT_STREAM_TYPE) - .setUsage(AudioAttributes.USAGE_GAME) - .build() - // make a new SoundPool, allowing up to 100 streams - SoundPool.Builder() - .setAudioAttributes(attrs) - .setMaxStreams(100) - .build() - } else { - // make a new SoundPool, allowing up to 100 streams - @Suppress("DEPRECATION") - SoundPool(100, AudioManager.STREAM_MUSIC, 0) - } - } - - init { - soundPool.setOnLoadCompleteListener { _, sampleId, _ -> - Logger.info("Loaded $sampleId") - val loadingPlayer = soundIdToPlayer[sampleId] - if (loadingPlayer != null) { - soundIdToPlayer.remove(loadingPlayer.soundId) - // Now mark all players using this sound as not loading and start them if necessary - synchronized(urlToPlayers) { - val urlPlayers = urlToPlayers[loadingPlayer.url] ?: listOf() - for (player in urlPlayers) { - Logger.info("Marking $player as loaded") - player.loading = false - if (player.playing) { - Logger.info("Delayed start of $player") - player.start() - } - } - } - } - } - } - } - - private var url: String? = null - private var volume = 1.0f - private var rate = 1.0f - private var soundId: Int? = null - private var streamId: Int? = null - private var playing = false - private var paused = false - private var looping = false - private var loading = false - - override fun play() { - if (!loading) { - start() - } - playing = true - paused = false - } - - override fun stop() { - if (playing) { - streamId?.let { soundPool.stop(it) } - playing = false - } - paused = false - } - - override fun release() { - stop() - val soundId = this.soundId ?: return - val url = this.url ?: return - - synchronized(urlToPlayers) { - val playersForSoundId = urlToPlayers[url] ?: return - if (playersForSoundId.singleOrNull() === this) { - urlToPlayers.remove(url) - soundPool.unload(soundId) - soundIdToPlayer.remove(soundId) - this.soundId = null - Logger.info("unloaded soundId $soundId") - } else { - // This is not the last player using the soundId, just remove it from the list. - playersForSoundId.remove(this) - } - - } - } - - override fun pause() { - if (playing) { - streamId?.let { soundPool.pause(it) } - } - playing = false - paused = true - } - - override fun setDataSource(mediaDataSource: MediaDataSource?) { - throw unsupportedOperation("setDataSource") - } - - override fun setUrl(url: String, isLocal: Boolean) { - if (this.url != null && this.url == url) { - return - } - if (soundId != null) { - release() - } - synchronized(urlToPlayers) { - this.url = url - val urlPlayers = urlToPlayers.getOrPut(url) { mutableListOf() } - val originalPlayer = urlPlayers.firstOrNull() - - if (originalPlayer != null) { - // Sound has already been loaded - reuse the soundId. - loading = originalPlayer.loading - soundId = originalPlayer.soundId - Logger.info("Reusing soundId $soundId for $url is loading=$loading $this") - } else { - // First one for this URL - load it. - val start = System.currentTimeMillis() - - loading = true - soundId = soundPool.load(getAudioPath(url, isLocal), 1) - soundIdToPlayer[soundId] = this - - Logger.info("time to call load() for $url: ${System.currentTimeMillis() - start} player=$this") - } - urlPlayers.add(this) - } - } - - override fun setVolume(volume: Double) { - this.volume = volume.toFloat() - if (playing) { - streamId?.let { soundPool.setVolume(it, this.volume, this.volume) } - } - } - - override fun setRate(rate: Double) { - this.rate = rate.toFloat() - if (streamId != null) { - streamId?.let { soundPool.setRate(it, this.rate) } - } - } - - override fun configAttributes( - respectSilence: Boolean, - stayAwake: Boolean, - duckAudio: Boolean - ) = Unit - - override fun setReleaseMode(releaseMode: ReleaseMode) { - looping = releaseMode === ReleaseMode.LOOP - if (playing) { - streamId?.let { soundPool.setLoop(it, loopModeInteger()) } - } - } - - override fun getDuration() = throw unsupportedOperation("getDuration") - - override fun getCurrentPosition() = throw unsupportedOperation("getDuration") - - override fun isActuallyPlaying(): Boolean = false - - override fun setPlayingRoute(playingRoute: String) { - throw unsupportedOperation("setPlayingRoute") - } - - override fun seek(position: Int) { - throw unsupportedOperation("seek") - } - - private fun start() { - setRate(rate.toDouble()) - if (paused) { - streamId?.let { soundPool.resume(it) } - paused = false - } else { - val soundId = this.soundId ?: return - streamId = soundPool.play( - soundId, - volume, - volume, - 0, - loopModeInteger(), - 1.0f - ) - } - } - - /** Integer representation of the loop mode used by Android */ - private fun loopModeInteger(): Int = if (looping) -1 else 0 - - private fun getAudioPath(url: String?, isLocal: Boolean): String? { - if (isLocal) { - return url?.removePrefix("file://") - } - - return loadTempFileFromNetwork(url).absolutePath - } - - private fun loadTempFileFromNetwork(url: String?): File { - val bytes = downloadUrl(URI.create(url).toURL()) - val tempFile = File.createTempFile("sound", "") - FileOutputStream(tempFile).use { - it.write(bytes) - tempFile.deleteOnExit() - } - return tempFile - } - - private fun downloadUrl(url: URL): ByteArray { - val outputStream = ByteArrayOutputStream() - url.openStream().use { stream -> - val chunk = ByteArray(4096) - while (true) { - val bytesRead = stream.read(chunk).takeIf { it > 0 } ?: break - outputStream.write(chunk, 0, bytesRead) - } - } - return outputStream.toByteArray() - } - - private fun unsupportedOperation(message: String): UnsupportedOperationException { - return UnsupportedOperationException("LOW_LATENCY mode does not support: $message") - } -} diff --git a/packages/audioplayers/android/src/test/kotlin/xyz/luan/audioplayers/LoggerTest.kt b/packages/audioplayers/android/src/test/kotlin/xyz/luan/audioplayers/LoggerTest.kt deleted file mode 100644 index 52ee60f58..000000000 --- a/packages/audioplayers/android/src/test/kotlin/xyz/luan/audioplayers/LoggerTest.kt +++ /dev/null @@ -1,39 +0,0 @@ -package xyz.luan.audioplayers - -import org.junit.jupiter.api.Test -import org.assertj.core.api.Assertions.assertThat - -internal class LoggerTest { - @Test - fun `when set to INFO everything is logged`() { - val logs = mockLogger() - Logger.logLevel = LogLevel.INFO - Logger.info("info") - Logger.error("error") - assertThat(logs).containsExactly("info", "error") - } - - @Test - fun `when set to ERROR only errors are logged`() { - val logs = mockLogger() - Logger.logLevel = LogLevel.ERROR - Logger.info("info") - Logger.error("error") - assertThat(logs).containsExactly("error") - } - - @Test - fun `when set to NONE nothing is logged`() { - val logs = mockLogger() - Logger.logLevel = LogLevel.NONE - Logger.info("info") - Logger.error("error") - assertThat(logs).isEmpty() - } - - private fun mockLogger(): MutableList { - val logs = mutableListOf() - Logger.androidLogger = { _, m, _ -> logs.add(m) } - return logs - } -} diff --git a/packages/audioplayers/darwin/.gitignore b/packages/audioplayers/darwin/.gitignore deleted file mode 100644 index 956c87f3a..000000000 --- a/packages/audioplayers/darwin/.gitignore +++ /dev/null @@ -1,31 +0,0 @@ -.idea/ -.vagrant/ -.sconsign.dblite -.svn/ - -.DS_Store -*.swp -profile - -DerivedData/ -build/ - -*.pbxuser -*.mode1v3 -*.mode2v3 -*.perspectivev3 - -!default.pbxuser -!default.mode1v3 -!default.mode2v3 -!default.perspectivev3 - -xcuserdata - -*.moved-aside - -*.pyc -*sync/ -Icon? -.tags* - diff --git a/packages/audioplayers/darwin/Classes/AudioplayersPlugin.h b/packages/audioplayers/darwin/Classes/AudioplayersPlugin.h deleted file mode 100644 index 722f0c412..000000000 --- a/packages/audioplayers/darwin/Classes/AudioplayersPlugin.h +++ /dev/null @@ -1,10 +0,0 @@ -#import - -#if TARGET_OS_IPHONE - #import -#else - #import -#endif - -@interface AudioplayersPlugin : NSObject -@end diff --git a/packages/audioplayers/darwin/Classes/AudioplayersPlugin.m b/packages/audioplayers/darwin/Classes/AudioplayersPlugin.m deleted file mode 100644 index a9cde1ea9..000000000 --- a/packages/audioplayers/darwin/Classes/AudioplayersPlugin.m +++ /dev/null @@ -1,15 +0,0 @@ -#import "AudioplayersPlugin.h" -#if __has_include() -#import -#else -// Support project import fallback if the generated compatibility header -// is not copied when this plugin is created as a library. -// https://forums.swift.org/t/swift-static-libraries-dont-copy-generated-objective-c-header/19816 -#import "audioplayers-Swift.h" -#endif - -@implementation AudioplayersPlugin -+ (void)registerWithRegistrar:(NSObject*)registrar { - [SwiftAudioplayersPlugin registerWithRegistrar:registrar]; -} -@end diff --git a/packages/audioplayers/darwin/Classes/Logger.swift b/packages/audioplayers/darwin/Classes/Logger.swift deleted file mode 100644 index 4b9babd3a..000000000 --- a/packages/audioplayers/darwin/Classes/Logger.swift +++ /dev/null @@ -1,49 +0,0 @@ -enum LogLevel: Int { - case info = 0 - case error = 1 - case none = 2 -} - -extension LogLevel { - static func parse(_ value: String) -> LogLevel? { - switch (value) { - case "LogLevel.INFO": return LogLevel.info - case "LogLevel.ERROR": return LogLevel.error - case "LogLevel.NONE": return LogLevel.none - default: return nil - } - } -} - -class Logger { - static var logLevel = LogLevel.error - - static func info(_ items: Any...) { - _log(.info, items: items) - } - - static func error(_ items: Any...) { - _log(.error, items: items) - } - - static func log(_ level: LogLevel, _ items: Any...) { - _log(level, items: items) - } - - static func _log(_ level: LogLevel, items: [Any]) { - if level.rawValue > logLevel.rawValue { - return - } - - let string: String - if items.count == 1, let s = items.first as? String { - string = s - } else if items.count > 1, let format = items.first as? String, let arguments = Array(items[1.. UIImage? { - if urlString.hasPrefix("http") { - guard let url: URL = URL.init(string: urlString) else { - Logger.error("Error download image url, invalid url %@", urlString) - return nil - } - do { - let data = try Data(contentsOf: url) - return UIImage.init(data: data) - } catch { - Logger.error("Error download image url %@", error) - return nil - } - } else { - return UIImage.init(contentsOfFile: urlString) - } - } - - func updateForIos(playerId: String, time: CMTime, playbackRate: Double) { - if (infoCenter == nil || playerId != reference.lastPlayerId) { - return - } - // From `MPNowPlayingInfoPropertyElapsedPlaybackTime` docs -- it is not recommended to update this value frequently. - // Thus it should represent integer seconds and not an accurate `CMTime` value with fractions of a second - let elapsedTime = Int(time.seconds) - - var playingInfo: [String: Any?] = [ - MPMediaItemPropertyTitle: title, - MPMediaItemPropertyAlbumTitle: albumTitle, - MPMediaItemPropertyArtist: artist, - MPMediaItemPropertyPlaybackDuration: duration, - MPNowPlayingInfoPropertyElapsedPlaybackTime: elapsedTime, - MPNowPlayingInfoPropertyPlaybackRate: Float(playbackRate) - ] - - Logger.info("Updating playing info...") - - // fetch notification image in async fashion to avoid freezing UI - DispatchQueue.global().async() { [weak self] in - if let imageUrl = self?.imageUrl { - let artworkImage = NotificationsHandler.geneateImageFromUrl(urlString: imageUrl) - if let artworkImage = artworkImage { - if #available(iOS 10, *) { - let albumArt = MPMediaItemArtwork.init( - boundsSize: artworkImage.size, - requestHandler: { (size) -> UIImage in - return artworkImage - } - ) - playingInfo[MPMediaItemPropertyArtwork] = albumArt - } else { - let albumArt = MPMediaItemArtwork.init(image: artworkImage) - playingInfo[MPMediaItemPropertyArtwork] = albumArt - } - Logger.info("Will add custom album art") - } - } - - if let infoCenter = self?.infoCenter { - let filteredMap = playingInfo.filter { $0.value != nil }.mapValues { $0! } - Logger.info("Setting playing info: %@", filteredMap) - infoCenter.nowPlayingInfo = filteredMap - } - } - } - - func setNotificationForIos( - playerId: String, - title: String?, - albumTitle: String?, - artist: String?, - imageUrl: String?, - forwardSkipInterval: Int, - backwardSkipInterval: Int, - duration: Int?, - elapsedTime: Int, - enablePreviousTrackButton: Bool?, - enableNextTrackButton: Bool? - ) { - self.title = title - self.albumTitle = albumTitle - self.artist = artist - self.imageUrl = imageUrl - self.duration = duration - - self.infoCenter = MPNowPlayingInfoCenter.default() - reference.lastPlayerId = playerId - reference.updateNotifications(player: reference.lastPlayer()!, time: toCMTime(millis: elapsedTime)) - - if (remoteCommandCenter == nil) { - remoteCommandCenter = MPRemoteCommandCenter.shared() - - if (forwardSkipInterval > 0 || backwardSkipInterval > 0) { - let skipBackwardIntervalCommand = remoteCommandCenter!.skipBackwardCommand - skipBackwardIntervalCommand.isEnabled = true - skipBackwardIntervalCommand.addTarget(handler: self.skipBackwardEvent) - skipBackwardIntervalCommand.preferredIntervals = [backwardSkipInterval as NSNumber] - - let skipForwardIntervalCommand = remoteCommandCenter!.skipForwardCommand - skipForwardIntervalCommand.isEnabled = true - skipForwardIntervalCommand.addTarget(handler: self.skipForwardEvent) - skipForwardIntervalCommand.preferredIntervals = [forwardSkipInterval as NSNumber] // Max 99 - } else { // if skip interval not set using next and previous - let nextTrackCommand = remoteCommandCenter!.nextTrackCommand - nextTrackCommand.isEnabled = enableNextTrackButton ?? false - nextTrackCommand.addTarget(handler: self.nextTrackEvent) - - let previousTrackCommand = remoteCommandCenter!.previousTrackCommand - previousTrackCommand.isEnabled = enablePreviousTrackButton ?? false - previousTrackCommand.addTarget(handler: self.previousTrackEvent) - } - - let pauseCommand = remoteCommandCenter!.pauseCommand - pauseCommand.isEnabled = true - pauseCommand.addTarget(handler: self.playOrPauseEvent) - - let playCommand = remoteCommandCenter!.playCommand - playCommand.isEnabled = true - playCommand.addTarget(handler: self.playOrPauseEvent) - - let togglePlayPauseCommand = remoteCommandCenter!.togglePlayPauseCommand - togglePlayPauseCommand.isEnabled = true - togglePlayPauseCommand.addTarget(handler: self.playOrPauseEvent) - - if #available(iOS 9.1, *) { - let changePlaybackPositionCommand = remoteCommandCenter!.changePlaybackPositionCommand - changePlaybackPositionCommand.isEnabled = true - changePlaybackPositionCommand.addTarget(handler: self.onChangePlaybackPositionCommand) - } - } - } - - func skipBackwardEvent(skipEvent: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { - let interval = (skipEvent as! MPSkipIntervalCommandEvent).interval - Logger.info("Skip backward by %f", interval) - - guard let player = reference.lastPlayer() else { - return MPRemoteCommandHandlerStatus.commandFailed - } - - player.skipBackward(interval: interval) - return MPRemoteCommandHandlerStatus.success - } - - func skipForwardEvent(skipEvent: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { - let interval = (skipEvent as! MPSkipIntervalCommandEvent).interval - Logger.info("Skip forward by %f", interval) - - guard let player = reference.lastPlayer() else { - return MPRemoteCommandHandlerStatus.commandFailed - } - - player.skipForward(interval: interval) - return MPRemoteCommandHandlerStatus.success - } - - func nextTrackEvent(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { - guard let player = reference.lastPlayer() else { - return MPRemoteCommandHandlerStatus.commandFailed - } - reference.onGotNextTrackCommand(playerId: player.playerId) - return MPRemoteCommandHandlerStatus.success - } - - func previousTrackEvent(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { - guard let player = reference.lastPlayer() else { - return MPRemoteCommandHandlerStatus.commandFailed - } - reference.onGotPreviousTrackCommand(playerId: player.playerId) - return MPRemoteCommandHandlerStatus.success - } - - func playOrPauseEvent(event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { - guard let player = reference.lastPlayer() else { - return MPRemoteCommandHandlerStatus.commandFailed - } - - // TODO(luan) incorporate this into WrappedMediaPlayer - let playerState: String - if #available(iOS 10.0, *) { - if (player.isPlaying) { - player.pause() - playerState = "paused" - } else { - player.resume() - playerState = "playing" - } - } else { - // No fallback on earlier versions - return MPRemoteCommandHandlerStatus.commandFailed - } - - reference.onNotificationPlayerStateChanged(playerId: player.playerId, isPlaying: player.isPlaying) - onNotificationBackgroundPlayerStateChanged(playerId: player.playerId, value: playerState) - - return MPRemoteCommandHandlerStatus.success - - } - - func onChangePlaybackPositionCommand(changePositionEvent: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { - guard let player = reference.lastPlayer() else { - return MPRemoteCommandHandlerStatus.commandFailed - } - - let positionTime = (changePositionEvent as! MPChangePlaybackPositionCommandEvent).positionTime - Logger.info("changePlaybackPosition to %f", positionTime) - let newTime = toCMTime(millis: positionTime) - player.seek(time: newTime) - return MPRemoteCommandHandlerStatus.success - } - - #endif -} diff --git a/packages/audioplayers/darwin/Classes/SwiftAudioplayersPlugin.swift b/packages/audioplayers/darwin/Classes/SwiftAudioplayersPlugin.swift deleted file mode 100644 index 98eb99b72..000000000 --- a/packages/audioplayers/darwin/Classes/SwiftAudioplayersPlugin.swift +++ /dev/null @@ -1,410 +0,0 @@ -import AVKit -import AVFoundation - -#if os(iOS) -import Flutter -import UIKit -import MediaPlayer -#else -import FlutterMacOS -#endif - -#if os(iOS) -let OS_NAME = "iOS" -let ENABLE_NOTIFICATIONS_HANDLER = true -#else -let OS_NAME = "macOS" -let ENABLE_NOTIFICATIONS_HANDLER = false -#endif - -let CHANNEL_NAME = "xyz.luan/audioplayers" -let AudioplayersPluginStop = NSNotification.Name("AudioplayersPluginStop") - -public class SwiftAudioplayersPlugin: NSObject, FlutterPlugin { - - var registrar: FlutterPluginRegistrar - var channel: FlutterMethodChannel - var notificationsHandler: NotificationsHandler? = nil - - var players = [String : WrappedMediaPlayer]() - // last player that started playing, to be used for notifications command center - // TODO(luan): provide generic way to control this - var lastPlayerId: String? = nil - - var timeObservers = [TimeObserver]() - - var isDealloc = false - - init(registrar: FlutterPluginRegistrar, channel: FlutterMethodChannel) { - self.registrar = registrar - self.channel = channel - - super.init() - NotificationCenter.default.addObserver(self, selector: #selector(self.needStop), name: AudioplayersPluginStop, object: nil) - if ENABLE_NOTIFICATIONS_HANDLER { - notificationsHandler = NotificationsHandler(reference: self) - } - } - - public static func register(with registrar: FlutterPluginRegistrar) { - // TODO(luan) apparently there is a bug in Flutter causing some inconsistency between Flutter and FlutterMacOS - #if os(iOS) - let binaryMessenger = registrar.messenger() - #else - let binaryMessenger = registrar.messenger - #endif - - let channel = FlutterMethodChannel(name: CHANNEL_NAME, binaryMessenger: binaryMessenger) - - let instance = SwiftAudioplayersPlugin(registrar: registrar, channel: channel) - registrar.addMethodCallDelegate(instance, channel: channel) - } - - @objc func needStop() { - isDealloc = true - destroy() - } - - func destroy() { - for osberver in self.timeObservers { - osberver.player.removeTimeObserver(osberver.observer) - } - self.timeObservers = [] - - for (_, player) in self.players { - player.clearObservers() - } - self.players = [:] - } - - public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { - let method = call.method - - guard let args = call.arguments as? [String: Any] else { - Logger.error("Failed to parse call.arguments from Flutter.") - result(0) - return - } - - // global handlers (no playerId) - if method == "changeLogLevel" { - guard let valueName = args["value"] as! String? else { - Logger.error("Null value received on changeLogLevel") - result(0) - return - } - guard let value = LogLevel.parse(valueName) else { - Logger.error("Invalid value received on changeLogLevel") - result(0) - return - } - - Logger.logLevel = value - result(1) - return - } - - guard let playerId = args["playerId"] as? String else { - Logger.error("Call missing mandatory parameter playerId.") - result(0) - return - } - Logger.info("%@ => call %@, playerId %@", OS_NAME, method, playerId) - - let player = self.getOrCreatePlayer(playerId: playerId) - - if method == "startHeadlessService" { - guard let handler = notificationsHandler else { - result(FlutterMethodNotImplemented) - return - } - if let handleKey = args["handleKey"] { - Logger.info("calling start headless service %@", handleKey) - let handle = (handleKey as! [Any])[0] - handler.startHeadlessService(handle: (handle as! Int64)) - } else { - result(0) - } - } else if method == "monitorNotificationStateChanges" { - guard let handler = notificationsHandler else { - result(FlutterMethodNotImplemented) - return - } - if let handleMonitorKey = args["handleMonitorKey"] { - Logger.info("calling monitor notification %@", handleMonitorKey) - let handle = (handleMonitorKey as! [Any])[0] - handler.updateHandleMonitorKey(handle: handle as! Int64) - } else { - result(0) - } - } else if method == "play" { - guard let url = args["url"] as! String? else { - Logger.error("Null url received on play") - result(0) - return - } - - let isLocal: Bool = (args["isLocal"] as? Bool) ?? true - let volume: Double = (args["volume"] as? Double) ?? 1.0 - - // we might or might not want to seek - let seekTimeMillis: Int = (args["position"] as? Int) ?? 0 - let seekTime: CMTime = toCMTime(millis: seekTimeMillis) - - let respectSilence: Bool = (args["respectSilence"] as? Bool) ?? false - let recordingActive: Bool = (args["recordingActive"] as? Bool) ?? false - let duckAudio: Bool = (args["duckAudio"] as? Bool) ?? false - - player.play( - url: url, - isLocal: isLocal, - volume: volume, - time: seekTime, - isNotification: respectSilence, - recordingActive: recordingActive, - duckAudio: duckAudio - ) - } else if method == "pause" { - player.pause() - } else if method == "resume" { - player.resume() - } else if method == "stop" { - player.stop() - } else if method == "release" { - player.release() - } else if method == "seek" { - let position = args["position"] as? Int - if let position = position { - let time = toCMTime(millis: position) - player.seek(time: time) - } else { - Logger.error("Null position received on seek") - result(0) - } - } else if method == "setUrl" { - let url: String? = args["url"] as? String - let isLocal: Bool = (args["isLocal"] as? Bool) ?? false - let respectSilence: Bool = (args["respectSilence"] as? Bool) ?? false - let recordingActive: Bool = (args["recordingActive"] as? Bool) ?? false - - if url == nil { - Logger.error("Null URL received on setUrl") - result(0) - return - } - - player.setUrl( - url: url!, - isLocal: isLocal, - isNotification: respectSilence, - recordingActive: recordingActive, - duckAudio: false - ) { - player in - result(1) - } - } else if method == "getDuration" { - let duration = player.getDuration() - result(duration) - } else if method == "setVolume" { - guard let volume = args["volume"] as? Double else { - Logger.error("Error calling setVolume, volume cannot be null") - result(0) - return - } - - player.setVolume(volume: volume) - } else if method == "getCurrentPosition" { - let currentPosition = player.getCurrentPosition() - result(currentPosition) - } else if method == "setPlaybackRate" { - guard let playbackRate = args["playbackRate"] as? Double else { - Logger.error("Error calling setPlaybackRate, playbackRate cannot be null") - result(0) - return - } - player.setPlaybackRate(playbackRate: playbackRate) - } else if method == "setReleaseMode" { - guard let releaseMode = args["releaseMode"] as? String else { - Logger.error("Error calling setReleaseMode, releaseMode cannot be null") - result(0) - return - } - let looping = releaseMode.hasSuffix("LOOP") - player.looping = looping - } else if method == "earpieceOrSpeakersToggle" { - guard let playingRoute = args["playingRoute"] as? String else { - Logger.error("Error calling earpieceOrSpeakersToggle, playingRoute cannot be null") - result(0) - return - } - self.setPlayingRoute(playerId: playerId, playingRoute: playingRoute) - } else if method == "setNotification" { - let title = args["title"] as? String - let albumTitle = args["albumTitle"] as? String - let artist = args["artist"] as? String - let imageUrl = args["imageUrl"] as? String - - let forwardSkipInterval = args["forwardSkipInterval"] as? Int - let backwardSkipInterval = args["backwardSkipInterval"] as? Int - let duration = args["duration"] as? Int - let elapsedTime = args["elapsedTime"] as? Int - - let enablePreviousTrackButton = args["enablePreviousTrackButton"] as? Bool - let enableNextTrackButton = args["enableNextTrackButton"] as? Bool - - guard let handler = notificationsHandler else { - result(FlutterMethodNotImplemented) - return - } - // TODO(luan) reconsider whether these params are optional or not + default values/errors - handler.setNotification( - playerId: playerId, - title: title, - albumTitle: albumTitle, - artist: artist, - imageUrl: imageUrl, - forwardSkipInterval: forwardSkipInterval ?? 0, - backwardSkipInterval: backwardSkipInterval ?? 0, - duration: duration, - elapsedTime: elapsedTime!, - enablePreviousTrackButton: enablePreviousTrackButton, - enableNextTrackButton: enableNextTrackButton - ) - } else if method == "clearNotification" { - guard let handler = notificationsHandler else { - result(FlutterMethodNotImplemented) - return - } - handler.clearNotification() - player.release() - } else { - Logger.error("Called not implemented method: %@", method) - result(FlutterMethodNotImplemented) - return - } - - // shortcut to avoid requiring explicit call of result(1) everywhere - if method != "setUrl" { - result(1) - } - } - - func getOrCreatePlayer(playerId: String) -> WrappedMediaPlayer { - if let player = players[playerId] { - return player - } - let newPlayer = WrappedMediaPlayer( - reference: self, - playerId: playerId - ) - players[playerId] = newPlayer - return newPlayer - } - - func onSeekCompleted(playerId: String, finished: Bool) { - channel.invokeMethod("audio.onSeekComplete", arguments: ["playerId": playerId, "value": finished]) - } - - func onComplete(playerId: String) { - channel.invokeMethod("audio.onComplete", arguments: ["playerId": playerId]) - } - - func onCurrentPosition(playerId: String, millis: Int) { - channel.invokeMethod("audio.onCurrentPosition", arguments: ["playerId": playerId, "value": millis]) - } - - func onError(playerId: String) { - channel.invokeMethod("audio.onError", arguments: ["playerId": playerId, "value": "AVPlayerItem.Status.failed"]) - } - - func onDuration(playerId: String, millis: Int) { - channel.invokeMethod("audio.onDuration", arguments: ["playerId": playerId, "value": millis]) - } - - func onNotificationPlayerStateChanged(playerId: String, isPlaying: Bool) { - channel.invokeMethod("audio.onNotificationPlayerStateChanged", arguments: ["playerId": playerId, "value": isPlaying]) - } - - func onGotPreviousTrackCommand(playerId: String) { - channel.invokeMethod("audio.onGotPreviousTrackCommand", arguments: ["playerId": playerId]) - } - - func onGotNextTrackCommand(playerId: String) { - channel.invokeMethod("audio.onGotNextTrackCommand", arguments: ["playerId": playerId]) - } - - func updateCategory( - recordingActive: Bool, - isNotification: Bool, - playingRoute: String, - duckAudio: Bool - ) { - #if os(iOS) - // When using AVAudioSessionCategoryPlayback, by default, this implies that your app’s audio is nonmixable—activating your session - // will interrupt any other audio sessions which are also nonmixable. AVAudioSessionCategoryPlayback should not be used with - // AVAudioSessionCategoryOptionMixWithOthers option. If so, it prevents infoCenter from working correctly. - let category = (playingRoute == "earpiece" || recordingActive) ? AVAudioSession.Category.playAndRecord : ( - isNotification ? AVAudioSession.Category.ambient : AVAudioSession.Category.playback - ) - let options = isNotification || duckAudio ? AVAudioSession.CategoryOptions.mixWithOthers : [] - - configureAudioSession(category: category, options: options) - if isNotification { - UIApplication.shared.beginReceivingRemoteControlEvents() - } - #endif - } - - func maybeDeactivateAudioSession() { - let hasPlaying = players.values.contains { player in player.isPlaying } - if !hasPlaying { - #if os(iOS) - configureAudioSession(active: false) - #endif - } - } - - func lastPlayer() -> WrappedMediaPlayer? { - if let playerId = lastPlayerId { - return getOrCreatePlayer(playerId: playerId) - } else { - return nil - } - } - - func updateNotifications(player: WrappedMediaPlayer, time: CMTime) { - notificationsHandler?.update(playerId: player.playerId, time: time, playbackRate: player.playbackRate) - } - - // TODO(luan) this should not be here. is playingRoute player-specific or global? - func setPlayingRoute(playerId: String, playingRoute: String) { - let wrappedPlayer = players[playerId]! - wrappedPlayer.playingRoute = playingRoute - - #if os(iOS) - let category = playingRoute == "earpiece" ? AVAudioSession.Category.playAndRecord : AVAudioSession.Category.playback - configureAudioSession(category: category) - #endif - } - - #if os(iOS) - private func configureAudioSession( - category: AVAudioSession.Category? = nil, - options: AVAudioSession.CategoryOptions = [], - active: Bool? = nil - ) { - do { - let session = AVAudioSession.sharedInstance() - if let category = category { - try session.setCategory(category, options: options) - } - if let active = active { - try session.setActive(active) - } - } catch { - Logger.error("Error configuring audio session: %@", error) - } - } - #endif -} diff --git a/packages/audioplayers/darwin/Classes/Utils.swift b/packages/audioplayers/darwin/Classes/Utils.swift deleted file mode 100644 index 72bed8dff..000000000 --- a/packages/audioplayers/darwin/Classes/Utils.swift +++ /dev/null @@ -1,39 +0,0 @@ -import AVKit - -extension String { - func deletingPrefix(_ prefix: String) -> String { - guard self.hasPrefix(prefix) else { return self } - return String(self.dropFirst(prefix.count)) - } -} - -func toCMTime(millis: Int) -> CMTime { - return toCMTime(millis: Float(millis)) -} - -func toCMTime(millis: Double) -> CMTime { - return toCMTime(millis: Float(millis)) -} - -func toCMTime(millis: Float) -> CMTime { - return CMTimeMakeWithSeconds(Float64(millis) / 1000, preferredTimescale: Int32(NSEC_PER_SEC)) -} - -func fromCMTime(time: CMTime) -> Int { - let seconds: Float64 = CMTimeGetSeconds(time) - let milliseconds: Int = Int(seconds * 1000) - return milliseconds -} - -class TimeObserver { - let player: AVPlayer - let observer: Any - - init( - player: AVPlayer, - observer: Any - ) { - self.player = player - self.observer = observer - } -} diff --git a/packages/audioplayers/darwin/Classes/WrappedMediaPlayer.swift b/packages/audioplayers/darwin/Classes/WrappedMediaPlayer.swift deleted file mode 100644 index e435b6ee0..000000000 --- a/packages/audioplayers/darwin/Classes/WrappedMediaPlayer.swift +++ /dev/null @@ -1,321 +0,0 @@ -import AVKit - -private let defaultPlaybackRate: Double = 1.0 -private let defaultVolume: Double = 1.0 -private let defaultPlayingRoute = "speakers" - -class WrappedMediaPlayer { - var reference: SwiftAudioplayersPlugin - - var playerId: String - var player: AVPlayer? - - var observers: [TimeObserver] - var keyVakueObservation: NSKeyValueObservation? - - var isPlaying: Bool - var playbackRate: Double - var volume: Double - var playingRoute: String - var looping: Bool - var url: String? - var onReady: ((AVPlayer) -> Void)? - - init( - reference: SwiftAudioplayersPlugin, - playerId: String, - player: AVPlayer? = nil, - observers: [TimeObserver] = [], - - isPlaying: Bool = false, - playbackRate: Double = defaultPlaybackRate, - volume: Double = defaultVolume, - playingRoute: String = defaultPlayingRoute, - looping: Bool = false, - url: String? = nil, - onReady: ((AVPlayer) -> Void)? = nil - ) { - self.reference = reference - self.playerId = playerId - self.player = player - self.observers = observers - self.keyVakueObservation = nil - - self.isPlaying = isPlaying - self.playbackRate = playbackRate - self.volume = volume - self.playingRoute = playingRoute - self.looping = looping - self.url = url - self.onReady = onReady - } - - func clearObservers() { - for observer in observers { - NotificationCenter.default.removeObserver(observer.observer) - } - observers = [] - } - - func getDurationCMTime() -> CMTime? { - guard let currentItem = player?.currentItem else { - return nil - } - - return currentItem.asset.duration - } - - func getDuration() -> Int? { - guard let duration = getDurationCMTime() else { - return nil - } - return fromCMTime(time: duration) - } - - private func getCurrentCMTime() -> CMTime? { - guard let player = player else { - return nil - } - return player.currentTime() - } - - func getCurrentPosition() -> Int? { - guard let time = getCurrentCMTime() else { - return nil - } - return fromCMTime(time: time) - } - - func pause() { - isPlaying = false - player?.pause() - } - - func resume() { - isPlaying = true - if #available(iOS 10.0, macOS 10.12, *) { - player?.playImmediately(atRate: Float(playbackRate)) - } else { - player?.play() - } - - // update last player that was used - reference.lastPlayerId = playerId - } - - func setVolume(volume: Double) { - self.volume = volume - player?.volume = Float(volume) - } - - func setPlaybackRate(playbackRate: Double) { - self.playbackRate = playbackRate - player?.rate = Float(playbackRate) - - if let currentTime = getCurrentCMTime() { - reference.updateNotifications(player: self, time: currentTime) - } - } - - func seek(time: CMTime) { - guard let currentItem = player?.currentItem else { - return - } - // TODO(luan) currently when you seek, the play auto-unpuses. this should set a seekTo property, similar to what WrappedMediaPlayer - currentItem.seek(to: time) { - finished in - if finished { - self.reference.updateNotifications(player: self, time: time) - } - self.reference.onSeekCompleted(playerId: self.playerId, finished: finished) - } - } - - func skipForward(interval: TimeInterval) { - guard let currentTime = getCurrentCMTime() else { - Logger.error("Cannot skip forward, unable to determine currentTime") - return - } - guard let maxDuration = getDurationCMTime() else { - Logger.error("Cannot skip forward, unable to determine maxDuration") - return - } - let newTime = CMTimeAdd(currentTime, toCMTime(millis: interval * 1000)) - // if CMTime is more than max duration, limit it - let clampedTime = CMTimeGetSeconds(newTime) > CMTimeGetSeconds(maxDuration) ? maxDuration : newTime - seek(time: clampedTime) - } - - func skipBackward(interval: TimeInterval) { - guard let currentTime = getCurrentCMTime() else { - Logger.error("Cannot skip forward, unable to determine currentTime") - return - } - let newTime = CMTimeSubtract(currentTime, toCMTime(millis: interval * 1000)) - // if CMTime is negative, set it to zero - let clampedTime = CMTimeGetSeconds(newTime) < 0 ? toCMTime(millis: 0) : newTime - seek(time: clampedTime) - } - - func stop() { - pause() - seek(time: toCMTime(millis: 0)) - } - - func release() { - stop() - clearObservers() - } - - func onSoundComplete() { - if !isPlaying { - return - } - - pause() - if looping { - seek(time: toCMTime(millis: 0)) - resume() - } - - reference.maybeDeactivateAudioSession() - reference.onComplete(playerId: playerId) - reference.notificationsHandler?.onNotificationBackgroundPlayerStateChanged( - playerId: playerId, - value: "completed" - ) - } - - func onTimeInterval(time: CMTime) { - if reference.isDealloc { - return - } - let millis = fromCMTime(time: time) - reference.onCurrentPosition(playerId: playerId, millis: millis) - } - - func updateDuration() { - guard let duration = player?.currentItem?.asset.duration else { - return - } - if CMTimeGetSeconds(duration) > 0 { - let millis = fromCMTime(time: duration) - reference.onDuration(playerId: playerId, millis: millis) - } - } - - func setUrl( - url: String, - isLocal: Bool, - isNotification: Bool, - recordingActive: Bool, - duckAudio: Bool, - onReady: @escaping (AVPlayer) -> Void - ) { - reference.updateCategory( - recordingActive: recordingActive, - isNotification: isNotification, - playingRoute: playingRoute, - duckAudio: duckAudio - ) - let playbackStatus = player?.currentItem?.status - - if self.url != url || playbackStatus == .failed || playbackStatus == nil { - let parsedUrl = isLocal ? URL.init(fileURLWithPath: url.deletingPrefix("file://")) : URL.init(string: url)! - let playerItem = AVPlayerItem.init(url: parsedUrl) - playerItem.audioTimePitchAlgorithm = AVAudioTimePitchAlgorithm.timeDomain - let player: AVPlayer - if let existingPlayer = self.player { - keyVakueObservation?.invalidate() - self.url = url - clearObservers() - existingPlayer.replaceCurrentItem(with: playerItem) - player = existingPlayer - } else { - player = AVPlayer.init(playerItem: playerItem) - - self.player = player - self.observers = [] - self.url = url - - // stream player position - let interval = toCMTime(millis: 0.2) - let timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: nil) { - [weak self] time in - self!.onTimeInterval(time: time) - } - reference.timeObservers.append(TimeObserver(player: player, observer: timeObserver)) - } - - let anObserver = NotificationCenter.default.addObserver( - forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, - object: playerItem, - queue: nil - ) { - [weak self] (notification) in - self!.onSoundComplete() - } - self.observers.append(TimeObserver(player: player, observer: anObserver)) - - // is sound ready - self.onReady = onReady - let newKeyValueObservation = playerItem.observe(\AVPlayerItem.status) { (playerItem, change) in - let status = playerItem.status - Logger.info("player status: %@ change: %@", status, change) - - // Do something with the status... - if status == .readyToPlay { - self.updateDuration() - - if let onReady = self.onReady { - self.onReady = nil - onReady(self.player!) - } - } else if status == .failed { - self.reference.onError(playerId: self.playerId) - } - } - - keyVakueObservation?.invalidate() - keyVakueObservation = newKeyValueObservation - } else { - if playbackStatus == .readyToPlay { - onReady(player!) - } - } - } - - func play( - url: String, - isLocal: Bool, - volume: Double, - time: CMTime?, - isNotification: Bool, - recordingActive: Bool, - duckAudio: Bool - ) { - reference.updateCategory( - recordingActive: recordingActive, - isNotification: isNotification, - playingRoute: playingRoute, - duckAudio: duckAudio - ) - - setUrl( - url: url, - isLocal: isLocal, - isNotification: isNotification, - recordingActive: recordingActive, - duckAudio: duckAudio - ) { - player in - player.volume = Float(volume) - if let time = time { - player.seek(to: time) - } - self.resume() - } - - reference.lastPlayerId = playerId - } -} diff --git a/packages/audioplayers/doc/.gitignore b/packages/audioplayers/doc/.gitignore deleted file mode 100644 index 8c6492ca8..000000000 --- a/packages/audioplayers/doc/.gitignore +++ /dev/null @@ -1 +0,0 @@ -api/ diff --git a/packages/audioplayers/doc/audio_cache.md b/packages/audioplayers/doc/audio_cache.md deleted file mode 100644 index af3f580f5..000000000 --- a/packages/audioplayers/doc/audio_cache.md +++ /dev/null @@ -1,84 +0,0 @@ -# Audio Cache - -In order to play Local Assets, you must use the `AudioCache` class. AudioCache is not available for Flutter Web. - -Flutter does not provide an easy way to play audio on your assets, but this class helps a lot. It actually copies the asset to a temporary folder in the device, where it is then played as a Local File. - -It works as a cache because it keeps track of the copied files so that you can replay them without delay; you can also pre-load files. - -Each instance has its own independent cache, so, if you want to optimize your load times, create a singleton. - -To play an audio, just run: - -```dart - // import in the begining of the file - import 'package:audioplayers/audioplayers.dart'; - - // add it to your class as a static member - static AudioCache player = AudioCache(); - // or as a local variable - final player = AudioCache(); - - // call this method when desired - player.play('explosion.mp3'); -``` - -This will play the `explosion.mp3` file in your project's `assets` folder. - -The file structure would be something like this: - -``` -. -└── assets - └── explosion.mp3 -``` - -Don't forget to add these files to your `pubspec.yaml` file: - -``` -flutter: - assets: - - assets/explosion.mp3 -``` - -You can optionally pass a prefix to the constructor if all of your audios are in a specific folder inside the assets folder. [Flame](https://github.com/luanpotter/flame), for instance, uses the 'assets/audio/' prefix: - -```dart - AudioCache player = AudioCache(prefix: 'assets/audio/'); - player.play('explosion.mp3'); - // now this file will be loaded from assets/audio/explosion.mp3 -``` - -If you want to play indefinitely, just use the `loop` function: - -```dart - player.loop('music.mp3'); -``` - -Finally, you can pre-load your audios. Audios need to be copied the first time they are requested; therefore, the first time you play each mp3 you might get a delay. In order to pre-load your audios, just use: - -```dart - player.load('explosion.mp3'); -``` - -You can load all your audios in the beginning so that they always play smoothly; to load multiple audios, use the `loadAll` method: - -```dart - player.loadAll(['explosion.mp3', 'music.mp3']) -``` - -Finally, you can use the `clear` method to remove something from the cache: - -```dart - player.clear('explosion.mp3'); -``` - -There is also a `clearAll` method, that clears the whole cache. - -This might be useful if, for instance, your game has multiple levels and each has a different soundtrack. - -Both load methods return a `Future` for the loaded `File`s. - -Both on `play` and `loop` you can pass an additional optional double parameter, the `volume` (defaults to `1.0`). - -Both the `play` and `loop` methods return a new instance of `AudioPlayer`, that allows you to stop, pause and configure other specifications. diff --git a/packages/audioplayers/example/.metadata b/packages/audioplayers/example/.metadata index c9d64fa44..af5cda898 100644 --- a/packages/audioplayers/example/.metadata +++ b/packages/audioplayers/example/.metadata @@ -4,7 +4,42 @@ # This file should be version controlled and should not be manually edited. version: - revision: ec1044a8773e31b4630bf162d9c374236ad1eaaf - channel: master + revision: "edada7c56edf4a183c1735310e123c7f923584f1" + channel: "stable" project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: android + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: ios + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: linux + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: macos + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: web + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + - platform: windows + create_revision: edada7c56edf4a183c1735310e123c7f923584f1 + base_revision: edada7c56edf4a183c1735310e123c7f923584f1 + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/packages/audioplayers/example/README.md b/packages/audioplayers/example/README.md index 1d94dab5e..2fa4baf8b 100644 --- a/packages/audioplayers/example/README.md +++ b/packages/audioplayers/example/README.md @@ -1,11 +1,27 @@ # AudioPlayer Example This is an example usage of audioplayers plugin. +Check out the live [example app](https://bluefireteam.github.io/audioplayers/) as demonstration. -It's a simple app with three tabs. +It's a simple app with several tabs: +- **Src**: Manage audio sources. + - Url: Plays audio from a remote Url from the Internet. + - Asset: Play one of the assets bundled with this app. + - Device File: Play a file from your device from the specified path. + - Byte Array: Play from an array of bytes. +- **Ctrl**: Control playback, such as volume, balance and rate. +- **Stream**: Display of stream updates and properties. +- **Ctx**: Customize the audio context for mobile devices. +- **Log**: Display of logs. - - Remote Url: Plays audio from a remote url from the Internet. - - Local File: Downloads a file to your device in order to play it from your device. - - Local Asset: Play one of the assets bundled with this app. +This example bundles a `PlayerWidget` that could be used as a very simple audio player interface. -This example bundles a `PlayerWidget` that could be used as a very simple audio player interface. \ No newline at end of file +## Setup + +In order to successfully run the example locally, you have to [set up](https://github.com/bluefireteam/audioplayers/blob/main/contributing.md#environment-setup) your environment with `melos`. + +## Dart Environment Variables + +Set the following variables as additional args `--dart-define MY_VAR=xyz`: + +- `USE_LOCAL_SERVER`: uses links to local server instead of public accessible links, default: `false`. diff --git a/packages/audioplayers/example/analysis_options.yaml b/packages/audioplayers/example/analysis_options.yaml index 85732fa02..f94626741 100644 --- a/packages/audioplayers/example/analysis_options.yaml +++ b/packages/audioplayers/example/analysis_options.yaml @@ -1 +1,5 @@ include: package:flame_lint/analysis_options.yaml + +linter: + rules: + do_not_use_environment: false diff --git a/packages/audioplayers/example/android/.gitignore b/packages/audioplayers/example/android/.gitignore index 0a741cb43..be3943c96 100644 --- a/packages/audioplayers/example/android/.gitignore +++ b/packages/audioplayers/example/android/.gitignore @@ -5,7 +5,10 @@ gradle-wrapper.jar /gradlew.bat /local.properties GeneratedPluginRegistrant.java +.cxx/ # Remember to never publicly share your keystore. -# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +# See https://flutter.dev/to/reference-keystore key.properties +**/*.keystore +**/*.jks diff --git a/packages/audioplayers/example/android/app/build.gradle b/packages/audioplayers/example/android/app/build.gradle deleted file mode 100644 index 86968d9a1..000000000 --- a/packages/audioplayers/example/android/app/build.gradle +++ /dev/null @@ -1,61 +0,0 @@ -def localProperties = new Properties() -def localPropertiesFile = rootProject.file('local.properties') -if (localPropertiesFile.exists()) { - localPropertiesFile.withReader('UTF-8') { reader -> - localProperties.load(reader) - } -} - -def flutterRoot = localProperties.getProperty('flutter.sdk') -if (flutterRoot == null) { - throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") -} - -def flutterVersionCode = localProperties.getProperty('flutter.versionCode') -if (flutterVersionCode == null) { - flutterVersionCode = '1' -} - -def flutterVersionName = localProperties.getProperty('flutter.versionName') -if (flutterVersionName == null) { - flutterVersionName = '1.0' -} - -apply plugin: 'com.android.application' -apply plugin: 'kotlin-android' -apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" - -android { - compileSdkVersion 29 - - sourceSets { - main.java.srcDirs += 'src/main/kotlin' - } - - lintOptions { - disable 'InvalidPackage' - } - - defaultConfig { - applicationId "xyz.luan.audioplayers.example" - minSdkVersion 16 - targetSdkVersion 29 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName - } - - buildTypes { - release { - signingConfig signingConfigs.debug - } - } - buildToolsVersion '29.0.2' -} - -flutter { - source '../..' -} - -dependencies { - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" -} diff --git a/packages/audioplayers/example/android/app/build.gradle.kts b/packages/audioplayers/example/android/app/build.gradle.kts new file mode 100644 index 000000000..55e7ff119 --- /dev/null +++ b/packages/audioplayers/example/android/app/build.gradle.kts @@ -0,0 +1,43 @@ +plugins { + id("com.android.application") + id("kotlin-android") + // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. + id("dev.flutter.flutter-gradle-plugin") +} + +android { + namespace = "xyz.luan.audioplayers.example" + // [Audioplayers] We try to use the values specified by Flutter, so compatible versions are used out of the box. + compileSdk = flutter.compileSdkVersion + // The prompt to upgrade the NDK version usually is just a warning and can be ignored. + ndkVersion = flutter.ndkVersion + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = JavaVersion.VERSION_17.toString() + } + + defaultConfig { + applicationId = "xyz.luan.audioplayers.example" + // TODO: Change back to `minSdk = flutter.minSdkVersion` when min supported Flutter version is 3.35.x. + minSdk = 23 + targetSdk = flutter.targetSdkVersion + versionCode = flutter.versionCode + versionName = flutter.versionName + } + + buildTypes { + release { + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig = signingConfigs.getByName("debug") + } + } +} + +flutter { + source = "../.." +} diff --git a/packages/audioplayers/example/android/app/src/debug/AndroidManifest.xml b/packages/audioplayers/example/android/app/src/debug/AndroidManifest.xml index 234d314d0..399f6981d 100644 --- a/packages/audioplayers/example/android/app/src/debug/AndroidManifest.xml +++ b/packages/audioplayers/example/android/app/src/debug/AndroidManifest.xml @@ -1,6 +1,6 @@ - - diff --git a/packages/audioplayers/example/android/app/src/main/AndroidManifest.xml b/packages/audioplayers/example/android/app/src/main/AndroidManifest.xml index 55de59622..46f17475a 100644 --- a/packages/audioplayers/example/android/app/src/main/AndroidManifest.xml +++ b/packages/audioplayers/example/android/app/src/main/AndroidManifest.xml @@ -1,18 +1,17 @@ - - + + + + - + android:icon="@mipmap/ic_launcher" + android:usesCleartextTraffic="true"> - - @@ -45,4 +35,15 @@ android:name="flutterEmbedding" android:value="2" /> + + + + + + + diff --git a/packages/audioplayers/example/android/app/src/main/kotlin/xyz/luan/audioplayers/example/MainActivity.kt b/packages/audioplayers/example/android/app/src/main/kotlin/xyz/luan/audioplayers/example/MainActivity.kt index f36e91b7d..9d43d9f3e 100644 --- a/packages/audioplayers/example/android/app/src/main/kotlin/xyz/luan/audioplayers/example/MainActivity.kt +++ b/packages/audioplayers/example/android/app/src/main/kotlin/xyz/luan/audioplayers/example/MainActivity.kt @@ -2,5 +2,4 @@ package xyz.luan.audioplayers.example import io.flutter.embedding.android.FlutterActivity -class MainActivity: FlutterActivity() { -} +class MainActivity : FlutterActivity() diff --git a/packages/audioplayers/example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/audioplayers/example/android/app/src/main/res/drawable-v21/launch_background.xml new file mode 100644 index 000000000..f74085f3f --- /dev/null +++ b/packages/audioplayers/example/android/app/src/main/res/drawable-v21/launch_background.xml @@ -0,0 +1,12 @@ + + + + + + + + diff --git a/packages/audioplayers/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/audioplayers/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png index db77bb4b7..01314d359 100644 Binary files a/packages/audioplayers/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png and b/packages/audioplayers/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/packages/audioplayers/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/audioplayers/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png index 17987b79b..e7df9152e 100644 Binary files a/packages/audioplayers/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png and b/packages/audioplayers/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/packages/audioplayers/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/audioplayers/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png index 09d439148..b032a39d1 100644 Binary files a/packages/audioplayers/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png and b/packages/audioplayers/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/packages/audioplayers/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/audioplayers/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png index d5f1c8d34..387eea0ec 100644 Binary files a/packages/audioplayers/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png and b/packages/audioplayers/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/packages/audioplayers/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/audioplayers/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png index 4d6372eeb..f1562c0f3 100644 Binary files a/packages/audioplayers/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png and b/packages/audioplayers/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/packages/audioplayers/example/android/app/src/main/res/values-night/styles.xml b/packages/audioplayers/example/android/app/src/main/res/values-night/styles.xml index 449a9f930..06952be74 100644 --- a/packages/audioplayers/example/android/app/src/main/res/values-night/styles.xml +++ b/packages/audioplayers/example/android/app/src/main/res/values-night/styles.xml @@ -3,14 +3,14 @@ - diff --git a/packages/audioplayers/example/android/app/src/profile/AndroidManifest.xml b/packages/audioplayers/example/android/app/src/profile/AndroidManifest.xml index 234d314d0..399f6981d 100644 --- a/packages/audioplayers/example/android/app/src/profile/AndroidManifest.xml +++ b/packages/audioplayers/example/android/app/src/profile/AndroidManifest.xml @@ -1,6 +1,6 @@ - - diff --git a/packages/audioplayers/example/android/build.gradle b/packages/audioplayers/example/android/build.gradle deleted file mode 100644 index e8b72aa50..000000000 --- a/packages/audioplayers/example/android/build.gradle +++ /dev/null @@ -1,31 +0,0 @@ -buildscript { - ext.kotlin_version = '1.4.32' - repositories { - mavenCentral() - google() - } - - dependencies { - classpath 'com.android.tools.build:gradle:4.2.0' - classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - } -} - -allprojects { - repositories { - mavenCentral() - google() - } -} - -rootProject.buildDir = '../build' -subprojects { - project.buildDir = "${rootProject.buildDir}/${project.name}" -} -subprojects { - project.evaluationDependsOn(':app') -} - -task clean(type: Delete) { - delete rootProject.buildDir -} diff --git a/packages/audioplayers/example/android/build.gradle.kts b/packages/audioplayers/example/android/build.gradle.kts new file mode 100644 index 000000000..dbee657bb --- /dev/null +++ b/packages/audioplayers/example/android/build.gradle.kts @@ -0,0 +1,24 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +val newBuildDir: Directory = + rootProject.layout.buildDirectory + .dir("../../build") + .get() +rootProject.layout.buildDirectory.value(newBuildDir) + +subprojects { + val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name) + project.layout.buildDirectory.value(newSubprojectBuildDir) +} +subprojects { + project.evaluationDependsOn(":app") +} + +tasks.register("clean") { + delete(rootProject.layout.buildDirectory) +} diff --git a/packages/audioplayers/example/android/gradle.properties b/packages/audioplayers/example/android/gradle.properties index 94adc3a3f..fbee1d8cd 100644 --- a/packages/audioplayers/example/android/gradle.properties +++ b/packages/audioplayers/example/android/gradle.properties @@ -1,3 +1,2 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError android.useAndroidX=true -android.enableJetifier=true diff --git a/packages/audioplayers/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/audioplayers/example/android/gradle/wrapper/gradle-wrapper.properties index d07459297..ac3b47926 100644 --- a/packages/audioplayers/example/android/gradle/wrapper/gradle-wrapper.properties +++ b/packages/audioplayers/example/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,5 @@ -#Wed Dec 30 17:06:26 EST 2020 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-6.7.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-all.zip diff --git a/packages/audioplayers/example/android/settings.gradle b/packages/audioplayers/example/android/settings.gradle deleted file mode 100644 index 44e62bcf0..000000000 --- a/packages/audioplayers/example/android/settings.gradle +++ /dev/null @@ -1,11 +0,0 @@ -include ':app' - -def localPropertiesFile = new File(rootProject.projectDir, "local.properties") -def properties = new Properties() - -assert localPropertiesFile.exists() -localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } - -def flutterSdkPath = properties.getProperty("flutter.sdk") -assert flutterSdkPath != null, "flutter.sdk not set in local.properties" -apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/packages/audioplayers/example/android/settings.gradle.kts b/packages/audioplayers/example/android/settings.gradle.kts new file mode 100644 index 000000000..fb605bc84 --- /dev/null +++ b/packages/audioplayers/example/android/settings.gradle.kts @@ -0,0 +1,26 @@ +pluginManagement { + val flutterSdkPath = + run { + val properties = java.util.Properties() + file("local.properties").inputStream().use { properties.load(it) } + val flutterSdkPath = properties.getProperty("flutter.sdk") + require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" } + flutterSdkPath + } + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id("dev.flutter.flutter-plugin-loader") version "1.0.0" + id("com.android.application") version "8.9.1" apply false + id("org.jetbrains.kotlin.android") version "2.1.0" apply false +} + +include(":app") diff --git a/packages/audioplayers/example/assets/ambient_c_motion.mp3 b/packages/audioplayers/example/assets/ambient_c_motion.mp3 new file mode 100644 index 000000000..40a85165a Binary files /dev/null and b/packages/audioplayers/example/assets/ambient_c_motion.mp3 differ diff --git a/packages/audioplayers/example/assets/audio.mp3 b/packages/audioplayers/example/assets/audio.mp3 deleted file mode 100644 index 4fabf9680..000000000 Binary files a/packages/audioplayers/example/assets/audio.mp3 and /dev/null differ diff --git a/packages/audioplayers/example/assets/audio2.mp3 b/packages/audioplayers/example/assets/audio2.mp3 deleted file mode 100644 index ff3b6c24b..000000000 Binary files a/packages/audioplayers/example/assets/audio2.mp3 and /dev/null differ diff --git a/packages/audioplayers/example/assets/coins whitespace.wav b/packages/audioplayers/example/assets/coins whitespace.wav new file mode 100644 index 000000000..c0dc31c28 Binary files /dev/null and b/packages/audioplayers/example/assets/coins whitespace.wav differ diff --git a/packages/audioplayers/example/assets/coins.mp3 b/packages/audioplayers/example/assets/coins.mp3 new file mode 100644 index 000000000..e44d17d52 Binary files /dev/null and b/packages/audioplayers/example/assets/coins.mp3 differ diff --git a/packages/audioplayers/example/assets/coins.wav b/packages/audioplayers/example/assets/coins.wav new file mode 100644 index 000000000..c0dc31c28 Binary files /dev/null and b/packages/audioplayers/example/assets/coins.wav differ diff --git a/packages/audioplayers/example/assets/coins_no_extension b/packages/audioplayers/example/assets/coins_no_extension new file mode 100644 index 000000000..c0dc31c28 Binary files /dev/null and b/packages/audioplayers/example/assets/coins_no_extension differ diff --git "a/packages/audioplayers/example/assets/coins_non_ascii_\320\270.wav" "b/packages/audioplayers/example/assets/coins_non_ascii_\320\270.wav" new file mode 100644 index 000000000..c0dc31c28 Binary files /dev/null and "b/packages/audioplayers/example/assets/coins_non_ascii_\320\270.wav" differ diff --git a/packages/audioplayers/example/assets/invalid.txt b/packages/audioplayers/example/assets/invalid.txt new file mode 100644 index 000000000..5cd09fd42 --- /dev/null +++ b/packages/audioplayers/example/assets/invalid.txt @@ -0,0 +1 @@ +This represents an invalid audio file. diff --git a/packages/audioplayers/example/assets/laser.wav b/packages/audioplayers/example/assets/laser.wav new file mode 100644 index 000000000..016326a3c Binary files /dev/null and b/packages/audioplayers/example/assets/laser.wav differ diff --git a/packages/audioplayers/example/assets/messenger.mp3 b/packages/audioplayers/example/assets/messenger.mp3 deleted file mode 100644 index a2ed90222..000000000 Binary files a/packages/audioplayers/example/assets/messenger.mp3 and /dev/null differ diff --git a/packages/audioplayers/example/assets/nasa_on_a_mission.mp3 b/packages/audioplayers/example/assets/nasa_on_a_mission.mp3 new file mode 100644 index 000000000..cce34a610 Binary files /dev/null and b/packages/audioplayers/example/assets/nasa_on_a_mission.mp3 differ diff --git a/packages/audioplayers/example/example.md b/packages/audioplayers/example/example.md new file mode 100644 index 000000000..11aa27162 --- /dev/null +++ b/packages/audioplayers/example/example.md @@ -0,0 +1,239 @@ +# Simple audio player app example + +A complete example showcasing all _audioplayers_ features can be found in our [repository](https://github.com/bluefireteam/audioplayers/tree/main/packages/audioplayers/example). +Also check out our live [web app](https://bluefireteam.github.io/audioplayers/). + +```dart +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; + +void main() { + runApp(const MaterialApp(home: _SimpleExampleApp())); +} + +class _SimpleExampleApp extends StatefulWidget { + const _SimpleExampleApp(); + + @override + _SimpleExampleAppState createState() => _SimpleExampleAppState(); +} + +class _SimpleExampleAppState extends State<_SimpleExampleApp> { + late AudioPlayer player = AudioPlayer(); + + @override + void initState() { + super.initState(); + + // Create the audio player. + player = AudioPlayer(); + + // Set the release mode to keep the source after playback has completed. + player.setReleaseMode(ReleaseMode.stop); + + // Start the player as soon as the app is displayed. + WidgetsBinding.instance.addPostFrameCallback((_) async { + await player.setSource(AssetSource('ambient_c_motion.mp3')); + await player.resume(); + }); + } + + @override + void dispose() { + // Release all sources and dispose the player. + player.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Simple Player'), + ), + body: PlayerWidget(player: player), + ); + } +} + +// The PlayerWidget is a copy of "/lib/components/player_widget.dart". +//#region PlayerWidget + +class PlayerWidget extends StatefulWidget { + final AudioPlayer player; + + const PlayerWidget({ + required this.player, + super.key, + }); + + @override + State createState() { + return _PlayerWidgetState(); + } +} + +class _PlayerWidgetState extends State { + PlayerState? _playerState; + Duration? _duration; + Duration? _position; + + StreamSubscription? _durationSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _playerCompleteSubscription; + StreamSubscription? _playerStateChangeSubscription; + + bool get _isPlaying => _playerState == PlayerState.playing; + + bool get _isPaused => _playerState == PlayerState.paused; + + String get _durationText => _duration?.toString().split('.').first ?? ''; + + String get _positionText => _position?.toString().split('.').first ?? ''; + + AudioPlayer get player => widget.player; + + @override + void initState() { + super.initState(); + // Use initial values from player + _playerState = player.state; + player.getDuration().then( + (value) => setState(() { + _duration = value; + }), + ); + player.getCurrentPosition().then( + (value) => setState(() { + _position = value; + }), + ); + _initStreams(); + } + + @override + void setState(VoidCallback fn) { + // Subscriptions only can be closed asynchronously, + // therefore events can occur after widget has been disposed. + if (mounted) { + super.setState(fn); + } + } + + @override + void dispose() { + _durationSubscription?.cancel(); + _positionSubscription?.cancel(); + _playerCompleteSubscription?.cancel(); + _playerStateChangeSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).primaryColor; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + key: const Key('play_button'), + onPressed: _isPlaying ? null : _play, + iconSize: 48.0, + icon: const Icon(Icons.play_arrow), + color: color, + ), + IconButton( + key: const Key('pause_button'), + onPressed: _isPlaying ? _pause : null, + iconSize: 48.0, + icon: const Icon(Icons.pause), + color: color, + ), + IconButton( + key: const Key('stop_button'), + onPressed: _isPlaying || _isPaused ? _stop : null, + iconSize: 48.0, + icon: const Icon(Icons.stop), + color: color, + ), + ], + ), + Slider( + onChanged: (value) { + final duration = _duration; + if (duration == null) { + return; + } + final position = value * duration.inMilliseconds; + player.seek(Duration(milliseconds: position.round())); + }, + value: (_position != null && + _duration != null && + _position!.inMilliseconds > 0 && + _position!.inMilliseconds < _duration!.inMilliseconds) + ? _position!.inMilliseconds / _duration!.inMilliseconds + : 0.0, + ), + Text( + _position != null + ? '$_positionText / $_durationText' + : _duration != null + ? _durationText + : '', + style: const TextStyle(fontSize: 16.0), + ), + ], + ); + } + + void _initStreams() { + _durationSubscription = player.onDurationChanged.listen((duration) { + setState(() => _duration = duration); + }); + + _positionSubscription = player.onPositionChanged.listen( + (p) => setState(() => _position = p), + ); + + _playerCompleteSubscription = player.onPlayerComplete.listen((event) { + setState(() { + _playerState = PlayerState.stopped; + _position = Duration.zero; + }); + }); + + _playerStateChangeSubscription = + player.onPlayerStateChanged.listen((state) { + setState(() { + _playerState = state; + }); + }); + } + + Future _play() async { + await player.resume(); + setState(() => _playerState = PlayerState.playing); + } + + Future _pause() async { + await player.pause(); + setState(() => _playerState = PlayerState.paused); + } + + Future _stop() async { + await player.stop(); + setState(() { + _playerState = PlayerState.stopped; + _position = Duration.zero; + }); + } +} + +//#endregion +``` diff --git a/packages/audioplayers/example/integration_test/app/app_source_test_data.dart b/packages/audioplayers/example/integration_test/app/app_source_test_data.dart new file mode 100644 index 000000000..1723ce151 --- /dev/null +++ b/packages/audioplayers/example/integration_test/app/app_source_test_data.dart @@ -0,0 +1,80 @@ +import '../platform_features.dart'; +import '../source_test_data.dart'; + +/// Data of a ui test source. +class AppSourceTestData extends SourceTestData { + String sourceKey; + + AppSourceTestData({ + required this.sourceKey, + required super.duration, + super.isVBR, + }); + + @override + String toString() { + return 'UiSourceTestData(' + 'sourceKey: $sourceKey, ' + 'duration: $duration, ' + 'isVBR: $isVBR' + ')'; + } +} + +final _features = PlatformFeatures.instance(); + +// All sources are tested again in lib or platform tests, +// therefore comment most of them to save testing time +final audioTestDataList = [ + if (_features.hasUrlSource) + AppSourceTestData( + sourceKey: 'url-remote-wav-1', + duration: const Duration(milliseconds: 451), + ), + /*if (_features.hasUrlSource) + AppSourceTestData( + sourceKey: 'url-remote-wav-2', + duration: const Duration(seconds: 1, milliseconds: 068), + ),*/ + /*if (_features.hasUrlSource) + AppSourceTestData( + sourceKey: 'url-remote-mp3-1', + isVBR: true, + duration: const Duration(minutes: 3, seconds: 30, milliseconds: 77), + ),*/ + /*if (_features.hasUrlSource) + AppSourceTestData( + sourceKey: 'url-remote-mp3-2', + duration: const Duration(minutes: 1, seconds: 34, milliseconds: 119), + ),*/ + if (_features.hasUrlSource && _features.hasPlaylistSourceType) + AppSourceTestData( + sourceKey: 'url-remote-m3u8', + duration: null, + ), + /*if (_features.hasUrlSource) + AppSourceTestData( + sourceKey: 'url-remote-mpga', + duration: null, + ),*/ + /*if (_features.hasAssetSource) + AppSourceTestData( + sourceKey: 'asset-wav', + duration: const Duration(seconds: 1, milliseconds: 068), + ),*/ + /*if (_features.hasAssetSource) + AppSourceTestData( + sourceKey: 'asset-mp3', + duration: const Duration(minutes: 1, seconds: 34, milliseconds: 119), + ),*/ + /*if (_features.hasBytesSource) + AppSourceTestData( + sourceKey: 'bytes-local', + duration: const Duration(seconds: 1, milliseconds: 068), + ),*/ + /*if (_features.hasBytesSource) + AppSourceTestData( + sourceKey: 'bytes-remote', + duration: const Duration(minutes: 3, seconds: 30, milliseconds: 76), + ),*/ +]; diff --git a/packages/audioplayers/example/integration_test/app/app_test_utils.dart b/packages/audioplayers/example/integration_test/app/app_test_utils.dart new file mode 100644 index 000000000..969f48b8c --- /dev/null +++ b/packages/audioplayers/example/integration_test/app/app_test_utils.dart @@ -0,0 +1,126 @@ +import 'package:audioplayers_example/components/tgl.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +extension AppWidgetTester on WidgetTester { + /// Wait until appearance and disappearance + Future waitOneshot( + Key key, { + Duration timeout = const Duration(seconds: 180), + String? stackTrace, + }) async { + await waitFor( + () async => expect( + find.byKey(key), + findsOneWidget, + ), + timeout: timeout, + pollInterval: const Duration(milliseconds: 100), + stackTrace: stackTrace, + ); + await waitFor( + () async => expect( + find.byKey(key), + findsNothing, + ), + stackTrace: stackTrace, + ); + } + + Future scrollToAndTap(Key widgetKey) async { + await scrollTo(widgetKey); + await tap(find.byKey(widgetKey)); + } + + Future scrollTo(Key widgetKey) async { + final finder = find.byKey(widgetKey); + if (finder.hitTestable().evaluate().isEmpty) { + await scrollUntilVisible( + finder, + 100, + scrollable: find.byType(Scrollable).first, + ); + } + await pump(); + } +} + +void expectWidgetHasText( + Key key, { + required Matcher matcher, + bool skipOffstage = true, +}) { + final widget = + find.byKey(key, skipOffstage: skipOffstage).evaluate().single.widget; + if (widget is Text) { + expect(widget.data, matcher); + } else { + throw 'Widget with key $key is not a Widget of type "Text"'; + } +} + +void expectWidgetHasDuration( + Key key, { + required dynamic matcher, + bool skipOffstage = true, +}) { + final widget = + find.byKey(key, skipOffstage: skipOffstage).evaluate().single.widget; + if (widget is Text) { + final regexp = RegExp(r'\d+:\d{2}:\d{2}.\d{6}'); + final match = regexp.firstMatch(widget.data ?? ''); + final duration = _parseDuration(match?.group(0)); + expect(duration, matcher); + } else { + throw 'Widget with key $key is not a Widget of type "Text"'; + } +} + +/// Parse Duration string to Duration +Duration? _parseDuration(String? s) { + if (s == null || s.isEmpty) { + return null; + } + var hours = 0; + var minutes = 0; + var micros = 0; + final parts = s.split(':'); + if (parts.length > 2) { + hours = int.parse(parts[parts.length - 3]); + } + if (parts.length > 1) { + minutes = int.parse(parts[parts.length - 2]); + } + micros = (double.parse(parts[parts.length - 1]) * 1000000).round(); + return Duration(hours: hours, minutes: minutes, microseconds: micros); +} + +void expectEnumToggleHasSelected( + Key key, { + required Matcher matcher, + bool skipOffstage = true, +}) { + final widget = + find.byKey(key, skipOffstage: skipOffstage).evaluate().single.widget; + if (widget is EnumTgl) { + expect(widget.selected, matcher); + } else { + throw 'Widget with key $key is not a Widget of type "EnumTgl"'; + } +} + +void expectToggleHasSelected( + Key key, { + required Matcher matcher, + bool skipOffstage = true, +}) { + final widget = + find.byKey(key, skipOffstage: skipOffstage).evaluate().single.widget; + if (widget is Tgl) { + expect(widget.selected, matcher); + } else { + throw 'Widget with key $key is not a Widget of type "Tgl"'; + } +} diff --git a/packages/audioplayers/example/integration_test/app/tabs/context_tab.dart b/packages/audioplayers/example/integration_test/app/tabs/context_tab.dart new file mode 100644 index 000000000..4d69a2dcb --- /dev/null +++ b/packages/audioplayers/example/integration_test/app/tabs/context_tab.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../platform_features.dart'; +import '../../test_utils.dart'; +import '../app_source_test_data.dart'; + +Future testContextTab( + WidgetTester tester, + AppSourceTestData audioSourceTestData, + PlatformFeatures features, +) async { + printWithTimeOnFailure('Test Context Tab'); + // Audio context + // TODO(Gustl22): test generic flags + // await tester.tap(find.byKey(const Key('audioContextTab'))); + // await tester.pumpAndSettle(); +} diff --git a/packages/audioplayers/example/integration_test/app/tabs/controls_tab.dart b/packages/audioplayers/example/integration_test/app/tabs/controls_tab.dart new file mode 100644 index 000000000..c26d6fb61 --- /dev/null +++ b/packages/audioplayers/example/integration_test/app/tabs/controls_tab.dart @@ -0,0 +1,253 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../platform_features.dart'; +import '../../test_utils.dart'; +import '../app_source_test_data.dart'; +import '../app_test_utils.dart'; +import 'properties.dart'; +import 'source_tab.dart'; + +Future testControlsTab( + WidgetTester tester, + AppSourceTestData audioSourceTestData, + PlatformFeatures features, +) async { + printWithTimeOnFailure('Test Controls Tab'); + await tester.tap(find.byKey(const Key('controlsTab'))); + await tester.pumpAndSettle(); + + // Sources take some time to get initialized + const stopDuration = Duration(seconds: 5); + + if (features.hasVolume) { + await tester.testVolume('0.5', stopDuration: stopDuration); + await tester.testVolume('0.0', stopDuration: stopDuration); + await tester.testVolume('1.0', stopDuration: stopDuration); + // No tests for volume > 1 + } + + if (features.hasBalance) { + await tester.testBalance('-1.0', stopDuration: stopDuration); + await tester.testBalance('1.0', stopDuration: stopDuration); + await tester.testBalance('0.0', stopDuration: stopDuration); + } + + if (features.hasPlaybackRate && !audioSourceTestData.isLiveStream) { + // TODO(Gustl22): also test for playback rate in streams + await tester.testRate('0.5'); + await tester.testRate('2.0'); + await tester.testRate('1.0'); + } + + if (features.hasSeek && !audioSourceTestData.isLiveStream) { + // TODO(Gustl22): also test seeking in streams + final isImmediateDurationSupported = features.hasMp3Duration || + !audioSourceTestData.sourceKey.contains('mp3'); + + // Linux cannot complete seek if duration is not present. + await tester.testSeek('0.5', isResume: false); + await tester.doInStreamsTab((tester) async { + if (isImmediateDurationSupported) { + await tester.testPosition( + Duration(seconds: audioSourceTestData.duration!.inSeconds ~/ 2), + matcher: (Object? value) => + greaterThanOrEqualTo(value ?? Duration.zero), + ); + } + }); + + await tester.pump(const Duration(seconds: 1)); + await tester.testSeek('1.0'); + await tester.pump(const Duration(seconds: 1)); + await tester.stop(); + } + + // Test all features in low latency mode: + final isBytesSource = audioSourceTestData.sourceKey.contains('bytes'); + if (features.hasLowLatency && + !audioSourceTestData.isLiveStream && + !isBytesSource) { + await tester.testPlayerMode(PlayerMode.lowLatency); + + // Test resume + await tester.resume(); + await tester.pump(const Duration(seconds: 1)); + // Test pause + await tester.scrollToAndTap(const Key('control-pause')); + await tester.resume(); + await tester.pump(const Duration(seconds: 1)); + await tester.stop(); + + // Test volume + await tester.testVolume('0.5'); + await tester.testVolume('1.0'); + + // Test release mode: loop + await tester.testReleaseMode(ReleaseMode.loop); + await tester.pump(const Duration(seconds: 3)); + await tester.stop(); + await tester.testReleaseMode(ReleaseMode.stop, isResume: false); + await tester.pumpAndSettle(); + + // Reset to media player + await tester.testPlayerMode(PlayerMode.mediaPlayer); + await tester.pumpAndSettle(); + } + + if (!audioSourceTestData.isLiveStream && + audioSourceTestData.duration! < const Duration(seconds: 2)) { + final isAndroid = + !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + // FIXME(gustl22): Android provides no position for samples shorter + // than 0.5 seconds. + if (features.hasReleaseModeLoop && + !(isAndroid && + audioSourceTestData.duration! < const Duration(seconds: 1))) { + await tester.testReleaseMode(ReleaseMode.loop); + await tester.pump(const Duration(seconds: 3)); + // Check if sound has started playing. + await tester.doInStreamsTab((tester) async { + await tester.testPosition( + Duration.zero, + matcher: (Duration? position) => + greaterThan(position ?? Duration.zero), + ); + }); + await tester.stop(); + await tester.testReleaseMode(ReleaseMode.stop, isResume: false); + await tester.pumpAndSettle(); + } + + if (features.hasReleaseModeRelease) { + await tester.testReleaseMode(ReleaseMode.release); + await tester.pump(const Duration(seconds: 3)); + // No need to call stop, as it should be released by now. + // Ensure source was released by checking `position == null`. + await tester.doInStreamsTab((tester) async { + await tester.testPosition(null); + }); + + // Reinitialize source + await tester.tap(find.byKey(const Key('sourcesTab'))); + await tester.pumpAndSettle(); + await tester.testSource(audioSourceTestData.sourceKey); + + await tester.tap(find.byKey(const Key('controlsTab'))); + await tester.pumpAndSettle(); + + await tester.testReleaseMode(ReleaseMode.stop, isResume: false); + await tester.pumpAndSettle(); + + // TODO(Gustl22): test 'control-release' + } + } +} + +extension ControlsWidgetTester on WidgetTester { + Future resume() async { + await scrollToAndTap(const Key('control-resume')); + await pump(); + } + + Future stop() async { + final st = StackTrace.current.toString(); + + await scrollToAndTap(const Key('control-stop')); + await waitOneshot(const Key('toast-player-stopped-0'), stackTrace: st); + await pump(); + } + + Future testVolume( + String volume, { + Duration stopDuration = const Duration(seconds: 1), + }) async { + printWithTimeOnFailure('Test Volume: $volume'); + await scrollToAndTap(Key('control-volume-$volume')); + await resume(); + await pump(stopDuration); + await stop(); + } + + Future testBalance( + String balance, { + Duration stopDuration = const Duration(seconds: 1), + }) async { + printWithTimeOnFailure('Test Balance: $balance'); + await scrollToAndTap(Key('control-balance-$balance')); + await resume(); + await pump(stopDuration); + await stop(); + } + + Future testRate( + String rate, { + Duration stopDuration = const Duration(seconds: 2), + }) async { + printWithTimeOnFailure('Test Rate: $rate'); + await scrollToAndTap(Key('control-rate-$rate')); + await resume(); + await pump(stopDuration); + await stop(); + } + + Future testSeek( + String seek, { + bool isResume = true, + }) async { + printWithTimeOnFailure('Test Seek: $seek'); + final st = StackTrace.current.toString(); + + await scrollToAndTap(Key('control-seek-$seek')); + + await waitOneshot(const Key('toast-seek-complete-0'), stackTrace: st); + + if (isResume) { + await resume(); + } + } + + Future testPlayerMode(PlayerMode mode) async { + printWithTimeOnFailure('Test Player Mode: ${mode.name}'); + final st = StackTrace.current.toString(); + + await scrollToAndTap(Key('control-player-mode-${mode.name}')); + await waitFor( + () async => expectEnumToggleHasSelected( + const Key('control-player-mode'), + matcher: equals(mode), + ), + stackTrace: st, + ); + } + + Future testReleaseMode(ReleaseMode mode, {bool isResume = true}) async { + printWithTimeOnFailure('Test Release Mode: ${mode.name}'); + final st = StackTrace.current.toString(); + + await scrollToAndTap(Key('control-release-mode-${mode.name}')); + await waitFor( + () async => expectEnumToggleHasSelected( + const Key('control-release-mode'), + matcher: equals(mode), + ), + stackTrace: st, + ); + if (isResume) { + await resume(); + } + } + + Future doInStreamsTab( + Future Function(WidgetTester tester) foo, + ) async { + await tap(find.byKey(const Key('streamsTab'))); + await pump(); + + await foo(this); + + await tap(find.byKey(const Key('controlsTab'))); + await pump(); + } +} diff --git a/packages/audioplayers/example/integration_test/app/tabs/logs_tab.dart b/packages/audioplayers/example/integration_test/app/tabs/logs_tab.dart new file mode 100644 index 000000000..a77be8ff6 --- /dev/null +++ b/packages/audioplayers/example/integration_test/app/tabs/logs_tab.dart @@ -0,0 +1,16 @@ +import 'package:flutter_test/flutter_test.dart'; + +import '../../platform_features.dart'; +import '../../test_utils.dart'; +import '../app_source_test_data.dart'; + +Future testLogsTab( + WidgetTester tester, + AppSourceTestData audioSourceTestData, + PlatformFeatures features, +) async { + printWithTimeOnFailure('Test Logs Tab'); + // TODO(Gustl22): may test logs + // await tester.tap(find.byKey(const Key('loggerTab'))); + // await tester.pumpAndSettle(); +} diff --git a/packages/audioplayers/example/integration_test/app/tabs/properties.dart b/packages/audioplayers/example/integration_test/app/tabs/properties.dart new file mode 100644 index 000000000..8c451f898 --- /dev/null +++ b/packages/audioplayers/example/integration_test/app/tabs/properties.dart @@ -0,0 +1,74 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../test_utils.dart'; +import '../app_test_utils.dart'; + +extension PropertiesWidgetTester on WidgetTester { + Future testDuration( + Duration? duration, { + Duration timeout = const Duration(seconds: 4), + }) async { + printWithTimeOnFailure('Test Duration: $duration'); + final st = StackTrace.current.toString(); + await waitFor( + () async { + await scrollToAndTap(const Key('refreshButton')); + await pump(); + expectWidgetHasDuration( + const Key('durationText'), + // TODO(gustl22): once duration is always null for streams, + // then can remove fallback for Duration.zero + matcher: (Duration? actual) => durationRangeMatcher( + actual ?? Duration.zero, + duration ?? Duration.zero, + ), + ); + }, + timeout: timeout, + stackTrace: st, + ); + } + + Future testPosition( + Duration? position, { + Matcher Function(Duration?) matcher = equals, + Duration timeout = const Duration(seconds: 4), + }) async { + printWithTimeOnFailure('Test Position: $position'); + final st = StackTrace.current.toString(); + await waitFor( + () async { + await scrollToAndTap(const Key('refreshButton')); + await pump(); + expectWidgetHasDuration( + const Key('positionText'), + matcher: matcher(position), + ); + }, + timeout: timeout, + stackTrace: st, + ); + } + + Future testPlayerState( + PlayerState playerState, { + Duration timeout = const Duration(seconds: 4), + }) async { + printWithTimeOnFailure('Test PlayerState: $playerState'); + final st = StackTrace.current.toString(); + await waitFor( + () async { + await scrollToAndTap(const Key('refreshButton')); + await pump(); + expectWidgetHasText( + const Key('playerStateText'), + matcher: contains(playerState.toString()), + ); + }, + timeout: timeout, + stackTrace: st, + ); + } +} diff --git a/packages/audioplayers/example/integration_test/app/tabs/source_tab.dart b/packages/audioplayers/example/integration_test/app/tabs/source_tab.dart new file mode 100644 index 000000000..f8dac11b0 --- /dev/null +++ b/packages/audioplayers/example/integration_test/app/tabs/source_tab.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../platform_features.dart'; +import '../../test_utils.dart'; +import '../app_source_test_data.dart'; +import '../app_test_utils.dart'; + +Future testSourcesTab( + WidgetTester tester, + AppSourceTestData audioSourceTestData, + PlatformFeatures features, +) async { + printWithTimeOnFailure('Test Sources Tab'); + await tester.tap(find.byKey(const Key('sourcesTab'))); + await tester.pumpAndSettle(); + + await tester.testSource(audioSourceTestData.sourceKey); +} + +extension ControlsWidgetTester on WidgetTester { + Future testSource(String sourceKey) async { + printWithTimeOnFailure('Test setting source: $sourceKey'); + final st = StackTrace.current.toString(); + final sourceWidgetKey = Key('setSource-$sourceKey'); + await scrollToAndTap(sourceWidgetKey); + + await waitOneshot(const Key('toast-set-source'), stackTrace: st); + } +} diff --git a/packages/audioplayers/example/integration_test/app/tabs/stream_tab.dart b/packages/audioplayers/example/integration_test/app/tabs/stream_tab.dart new file mode 100644 index 000000000..4b2beca60 --- /dev/null +++ b/packages/audioplayers/example/integration_test/app/tabs/stream_tab.dart @@ -0,0 +1,187 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../platform_features.dart'; +import '../../test_utils.dart'; +import '../app_source_test_data.dart'; +import '../app_test_utils.dart'; +import 'properties.dart'; + +Future testStreamsTab( + WidgetTester tester, + AppSourceTestData audioSourceTestData, + PlatformFeatures features, +) async { + printWithTimeOnFailure('Test Streams Tab'); + await tester.tap(find.byKey(const Key('streamsTab'))); + await tester.pumpAndSettle(); + + // Stream position is tracked as soon as source is loaded + // FIXME: Flaky position test for web, remove kIsWeb check. + if (!kIsWeb && !audioSourceTestData.isLiveStream) { + // Display position before playing + await tester.testPosition(Duration.zero); + } + + if (features.hasDurationEvent && !audioSourceTestData.isVBR) { + // Display duration before playing + await tester.testDuration(audioSourceTestData.duration); + } + + // Sources take some time to get initialized + const timeout = Duration(seconds: 8); + + await tester.pumpAndSettle(); + await tester.scrollToAndTap(const Key('play_button')); + await tester.pump(); + + // Cannot test more precisely as it is dependent on pollInterval + // and updateInterval of native implementation. + if (audioSourceTestData.isLiveStream || + audioSourceTestData.duration! > const Duration(seconds: 2)) { + // Test player state: playing + if (features.hasPlayerStateEvent) { + // Only test, if there's enough time to be able to check playing state. + await tester.testPlayerState(PlayerState.playing, timeout: timeout); + await tester.testOnPlayerState(PlayerState.playing, timeout: timeout); + } + + // Test if onPositionText is set. + await tester.testPosition( + Duration.zero, + matcher: (Duration? position) => greaterThan(position ?? Duration.zero), + timeout: timeout, + ); + await tester.testOnPosition( + Duration.zero, + matcher: greaterThan, + timeout: timeout, + ); + } + + if (features.hasDurationEvent && !audioSourceTestData.isLiveStream) { + // Test if onDurationText is set. + await tester.testOnDuration( + audioSourceTestData.duration!, + timeout: timeout, + ); + } + + const sampleDuration = Duration(seconds: 3); + await tester.pump(sampleDuration); + + // Test player states: pause, stop, completed + if (features.hasPlayerStateEvent) { + if (!audioSourceTestData.isLiveStream) { + if (audioSourceTestData.duration! < const Duration(seconds: 2)) { + await tester.testPlayerState(PlayerState.completed, timeout: timeout); + await tester.testOnPlayerState(PlayerState.completed, timeout: timeout); + } else if (audioSourceTestData.duration! > const Duration(seconds: 5)) { + await tester.scrollToAndTap(const Key('pause_button')); + await tester.pumpAndSettle(); + await tester.testPlayerState(PlayerState.paused); + await tester.testOnPlayerState(PlayerState.paused); + + await tester.stopStream(); + await tester.testPlayerState(PlayerState.stopped); + await tester.testOnPlayerState(PlayerState.stopped); + } else { + // Cannot say for sure, if it's stopped or completed, so we just stop + await tester.stopStream(); + } + } else { + await tester.stopStream(); + await tester.testPlayerState(PlayerState.stopped, timeout: timeout); + await tester.testOnPlayerState(PlayerState.stopped, timeout: timeout); + } + } + + // Display duration & position after completion / stop + // FIXME(Gustl22): Linux does not support duration after completion event + if (features.hasDurationEvent && + (kIsWeb || defaultTargetPlatform != TargetPlatform.linux)) { + await tester.testDuration(audioSourceTestData.duration); + if (!audioSourceTestData.isLiveStream) { + await tester.testOnDuration( + audioSourceTestData.duration!, + timeout: timeout, + ); + } + } + if (!audioSourceTestData.isLiveStream) { + await tester.testPosition(Duration.zero); + } +} + +extension StreamWidgetTester on WidgetTester { + // Precision for position & duration: + // Android: millisecond + // Windows: millisecond + // Linux: millisecond + // Web: millisecond + // Darwin: millisecond + + Future stopStream() async { + final st = StackTrace.current.toString(); + + await scrollToAndTap(const Key('stop_button')); + await waitOneshot(const Key('toast-player-stopped-0'), stackTrace: st); + await pumpAndSettle(); + } + + Future testOnDuration( + Duration duration, { + Duration timeout = const Duration(seconds: 10), + }) async { + printWithTimeOnFailure('Test OnDuration: $duration'); + final st = StackTrace.current.toString(); + await waitFor( + () async => expectWidgetHasDuration( + const Key('onDurationText'), + matcher: (Duration? actual) => durationRangeMatcher( + actual, + duration, + deviation: const Duration(milliseconds: 500), + ), + ), + timeout: timeout, + stackTrace: st, + ); + } + + Future testOnPosition( + Duration position, { + Matcher Function(Duration) matcher = equals, + Duration timeout = const Duration(seconds: 10), + }) async { + printWithTimeOnFailure('Test OnPosition: $position'); + final st = StackTrace.current.toString(); + await waitFor( + () async => expectWidgetHasDuration( + const Key('onPositionText'), + matcher: matcher(position), + ), + pollInterval: const Duration(milliseconds: 250), + timeout: timeout, + stackTrace: st, + ); + } + + Future testOnPlayerState( + PlayerState playerState, { + Duration timeout = const Duration(seconds: 10), + }) async { + printWithTimeOnFailure('Test OnState: $playerState'); + final st = StackTrace.current.toString(); + await waitFor( + () async => expectWidgetHasText( + const Key('onStateText'), + matcher: equals(playerState.toString()), + ), + pollInterval: const Duration(milliseconds: 250), + timeout: timeout, + stackTrace: st, + ); + } +} diff --git a/packages/audioplayers/example/integration_test/app_test.dart b/packages/audioplayers/example/integration_test/app_test.dart index 29b0e6f37..217c94a48 100644 --- a/packages/audioplayers/example/integration_test/app_test.dart +++ b/packages/audioplayers/example/integration_test/app_test.dart @@ -2,20 +2,43 @@ import 'package:audioplayers_example/main.dart' as app; import 'package:flutter_test/flutter_test.dart'; import 'package:integration_test/integration_test.dart'; +import 'app/app_source_test_data.dart'; +import 'app/tabs/context_tab.dart'; +import 'app/tabs/controls_tab.dart'; +import 'app/tabs/logs_tab.dart'; +import 'app/tabs/source_tab.dart'; +import 'app/tabs/stream_tab.dart'; +import 'platform_features.dart'; + void main() { + final features = PlatformFeatures.instance(); + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); group('end-to-end test', () { testWidgets('verify app is launched', (WidgetTester tester) async { app.main(); await tester.pumpAndSettle(); - print('running test!'); expect( - find.text( - 'Sample 1 (https://luan.xyz/files/audio/ambient_c_motion.mp3)', - ), + find.text('Remote URL WAV 1'), findsOneWidget, ); }); }); + + group('test functionality of sources', () { + for (final audioSourceTestData in audioTestDataList) { + testWidgets('test source $audioSourceTestData', + (WidgetTester tester) async { + app.main(); + await tester.pumpAndSettle(); + + await testSourcesTab(tester, audioSourceTestData, features); + await testControlsTab(tester, audioSourceTestData, features); + await testStreamsTab(tester, audioSourceTestData, features); + await testContextTab(tester, audioSourceTestData, features); + await testLogsTab(tester, audioSourceTestData, features); + }); + } + }); } diff --git a/packages/audioplayers/example/integration_test/lib/lib_source_test_data.dart b/packages/audioplayers/example/integration_test/lib/lib_source_test_data.dart new file mode 100644 index 000000000..d63dc78c3 --- /dev/null +++ b/packages/audioplayers/example/integration_test/lib/lib_source_test_data.dart @@ -0,0 +1,141 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_example/tabs/sources.dart'; +import 'package:http/http.dart'; + +import '../platform_features.dart'; +import '../source_test_data.dart'; + +/// Data of a library test source. +class LibSourceTestData extends SourceTestData { + Source source; + + LibSourceTestData({ + required this.source, + required super.duration, + super.isVBR, + }); + + @override + String toString() { + return 'LibSourceTestData(' + 'source: $source, ' + 'duration: $duration, ' + 'isVBR: $isVBR' + ')'; + } +} + +final _features = PlatformFeatures.instance(); + +final wavUrl1TestData = LibSourceTestData( + source: UrlSource(wavUrl1), + duration: const Duration(milliseconds: 451), +); + +final specialCharUrlTestData = LibSourceTestData( + source: UrlSource(wavUrl3), + duration: const Duration(milliseconds: 451), +); + +final mp3Url1TestData = LibSourceTestData( + source: UrlSource(mp3Url1), + duration: const Duration(minutes: 3, seconds: 30, milliseconds: 77), + isVBR: true, +); + +final m3u8UrlTestData = LibSourceTestData( + source: UrlSource(m3u8StreamUrl), + duration: null, +); + +final mpgaUrlTestData = LibSourceTestData( + source: UrlSource(mpgaStreamUrl), + duration: null, +); + +final wavAsset1TestData = LibSourceTestData( + source: AssetSource(wavAsset1), + duration: const Duration(milliseconds: 451), +); + +final wavAsset2TestData = LibSourceTestData( + source: AssetSource(wavAsset2), + duration: const Duration(seconds: 1, milliseconds: 068), +); + +final invalidAssetTestData = LibSourceTestData( + source: AssetSource(invalidAsset), + duration: null, +); + +final specialCharAssetTestData = LibSourceTestData( + source: AssetSource(specialCharAsset), + duration: const Duration(milliseconds: 451), +); + +final noExtensionAssetTestData = LibSourceTestData( + source: AssetSource(noExtensionAsset, mimeType: 'audio/wav'), + duration: const Duration(milliseconds: 451), +); + +final nonExistentUrlTestData = LibSourceTestData( + source: UrlSource('non_existent.txt'), + duration: null, +); + +final wavDataUriTestData = LibSourceTestData( + source: UrlSource(wavDataUri), + duration: const Duration(milliseconds: 451), +); + +final mp3DataUriTestData = LibSourceTestData( + source: UrlSource(mp3DataUri), + duration: const Duration(milliseconds: 444), +); + +Future mp3BytesTestData() async => LibSourceTestData( + source: BytesSource( + await readBytes(Uri.parse(mp3Url1)), + mimeType: 'audio/mpeg', + ), + duration: const Duration(minutes: 3, seconds: 30, milliseconds: 76), + ); + +// Some sources are commented which are considered redundant +Future> getAudioTestDataList() async { + return [ + if (_features.hasUrlSource) wavUrl1TestData, + /*if (_features.hasUrlSource) + LibSourceTestData( + source: UrlSource(wavUrl2), + duration: const Duration(seconds: 1, milliseconds: 068), + ),*/ + if (_features.hasUrlSource) mp3Url1TestData, + /*if (_features.hasUrlSource) + LibSourceTestData( + source: UrlSource(mp3Url2), + duration: const Duration(minutes: 1, seconds: 34, milliseconds: 119), + ),*/ + if (_features.hasUrlSource && _features.hasPlaylistSourceType) + m3u8UrlTestData, + if (_features.hasUrlSource) mpgaUrlTestData, + if (_features.hasDataUriSource) wavDataUriTestData, + // if (_features.hasDataUriSource) mp3DataUriTestData, + if (_features.hasAssetSource) wavAsset2TestData, + /*if (_features.hasAssetSource) + LibSourceTestData( + source: AssetSource(mp3Asset), + duration: const Duration(minutes: 1, seconds: 34, milliseconds: 119), + ),*/ + if (_features.hasBytesSource) await mp3BytesTestData(), + /*if (_features.hasBytesSource) + // Cache not working for web + LibSourceTestData( + source: BytesSource( + await AudioCache.instance.loadAsBytes(wavAsset2), + mimeType: 'audio/wav', + ), + duration: const Duration(seconds: 1, milliseconds: 068), + ),*/ + ]; +} diff --git a/packages/audioplayers/example/integration_test/lib/lib_test_utils.dart b/packages/audioplayers/example/integration_test/lib/lib_test_utils.dart new file mode 100644 index 000000000..552f080e7 --- /dev/null +++ b/packages/audioplayers/example/integration_test/lib/lib_test_utils.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension LibWidgetTester on WidgetTester { + Future pumpPlatform([ + Duration? duration, + EnginePhase phase = EnginePhase.sendSemanticsUpdate, + ]) async { + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.linux) { + // FIXME(1556): Pump on Linux doesn't work with GStreamer bus callback + await Future.delayed(duration ?? Duration.zero); + } else { + await pump(duration, phase); + } + } + + /// See [pumpFrames]. + Future pumpGlobalFrames( + Duration maxDuration, [ + Duration interval = const Duration(milliseconds: 16, microseconds: 683), + ]) { + var elapsed = Duration.zero; + return TestAsyncUtils.guard(() async { + binding.scheduleFrame(); + while (elapsed < maxDuration) { + await binding.pump(interval); + elapsed += interval; + } + }); + } +} diff --git a/packages/audioplayers/example/integration_test/lib_test.dart b/packages/audioplayers/example/integration_test/lib_test.dart new file mode 100644 index 000000000..044e1bc9b --- /dev/null +++ b/packages/audioplayers/example/integration_test/lib_test.dart @@ -0,0 +1,425 @@ +@Timeout(Duration(minutes: 5)) +library; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_example/tabs/sources.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'lib/lib_source_test_data.dart'; +import 'lib/lib_test_utils.dart'; +import 'platform_features.dart'; +import 'test_utils.dart'; + +void main() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final features = PlatformFeatures.instance(); + final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + final audioTestDataList = await getAudioTestDataList(); + + testWidgets('test asset source with special char', + (WidgetTester tester) async { + final player = AudioPlayer(); + + await player.play(specialCharAssetTestData.source); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }); + + testWidgets( + 'test device file source with special char', + (WidgetTester tester) async { + final player = AudioPlayer(); + + final path = await player.audioCache.loadPath(specialCharAsset); + expect(path, isNot(contains('%'))); // Ensure path is not URL encoded + await player.play(DeviceFileSource(path)); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }, + skip: kIsWeb, + ); + + testWidgets('test url source with special char', (WidgetTester tester) async { + final player = AudioPlayer(); + + await player.play(specialCharUrlTestData.source); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }); + + testWidgets( + 'test url source with no extension', + (WidgetTester tester) async { + final player = AudioPlayer(); + + await player.play(noExtensionAssetTestData.source); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }, + ); + + testWidgets('data URI source', (WidgetTester tester) async { + final player = AudioPlayer(); + + await player.play(mp3DataUriTestData.source); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }); + + testWidgets( + 'bytes array source', + (WidgetTester tester) async { + final player = AudioPlayer(); + + await player.play((await mp3BytesTestData()).source); + // Sources take some time to get initialized + await tester.pumpPlatform(const Duration(seconds: 8)); + await player.stop(); + await player.dispose(); + }, + skip: !features.hasBytesSource, + ); + + group('AP events', () { + late AudioPlayer player; + + setUp(() async { + player = AudioPlayer( + playerId: 'somePlayerId', + ); + }); + + void testPositionUpdater( + LibSourceTestData td, { + bool useTimerPositionUpdater = false, + }) { + final positionUpdaterName = useTimerPositionUpdater + ? 'TimerPositionUpdater' + : 'FramePositionUpdater'; + testWidgets( + '#positionEvent with $positionUpdaterName: ${td.source}', + (tester) async { + if (useTimerPositionUpdater) { + player.positionUpdater = TimerPositionUpdater( + getPosition: player.getCurrentPosition, + interval: const Duration(milliseconds: 100), + ); + } + final futurePositions = player.onPositionChanged.toList(); + + await player.setReleaseMode(ReleaseMode.stop); + await player.setSource(td.source); + await player.resume(); + await tester.pumpGlobalFrames(const Duration(seconds: 5)); + + if (!td.isLiveStream && td.duration! < const Duration(seconds: 2)) { + expect(player.state, PlayerState.completed); + } else { + if (td.isLiveStream || td.duration! > const Duration(seconds: 10)) { + expect(player.state, PlayerState.playing); + } else { + // Don't know for sure, if has yet completed or is still playing + } + await player.stop(); + expect(player.state, PlayerState.stopped); + } + await player.dispose(); + final positions = await futurePositions; + printOnFailure('Positions: $positions'); + expect(positions, isNot(contains(null))); + expect(positions, contains(greaterThan(Duration.zero))); + if (td.isLiveStream) { + // TODO(gustl22): Live streams may have zero or null as initial + // position. This should be consistent across all platforms. + } else { + expect(positions.first, Duration.zero); + expect(positions.last, Duration.zero); + } + }, + skip: + // FIXME(gustl22): [FLAKY] macos 13 fails on live streams. + (isMacOS && td.isLiveStream) || + // FIXME(gustl22): Android provides no position for samples + // shorter than 0.5 seconds. + (isAndroid && + !td.isLiveStream && + td.duration! < const Duration(seconds: 1)), + ); + } + + /// Test at least one source with [TimerPositionUpdater]. + testPositionUpdater(mp3Url1TestData, useTimerPositionUpdater: true); + + for (final td in audioTestDataList) { + testPositionUpdater(td); + } + }); + + group('play multiple sources', () { + testWidgets( + 'simultaneously', + (WidgetTester tester) async { + final players = + List.generate(audioTestDataList.length, (_) => AudioPlayer()); + + // Start all players simultaneously + final iterator = List.generate(audioTestDataList.length, (i) => i); + await Future.wait( + iterator.map( + (i) async => players[i].play(audioTestDataList[i].source), + ), + ); + final playerStates = List.generate( + audioTestDataList.length, + (index) => null, + ); + await tester.waitFor( + () async { + // TODO(gustl22): Improve detection of started players via player + // state. + final unplayed = playerStates + .mapIndexed( + (index, element) => element != null ? null : index, + ) + .nonNulls; + for (final i in unplayed) { + final player = players[i]; + if (player.state == PlayerState.completed || + player.state == PlayerState.disposed) { + playerStates[i] = player.state; + } else if (((await player.getCurrentPosition()) ?? + Duration.zero) > + Duration.zero) { + playerStates[i] = PlayerState.playing; + } + } + expect(playerStates, everyElement(isNotNull)); + }, + ); + await Future.wait(iterator.map((i) => players[i].stop())); + await Future.wait(players.map((p) => p.dispose())); + }, + // FIXME: Causes media error on Android (see #1333, #1353) + // Unexpected platform error: MediaPlayer error with + // what:MEDIA_ERROR_UNKNOWN {what:1} extra:MEDIA_ERROR_SYSTEM + // FIXME: Cannot play multiple players simultaneously at exactly the same + // time on Android Exo Player + skip: isAndroid, + ); + + testWidgets( + 'consecutively', + (WidgetTester tester) async { + final player = AudioPlayer(); + + for (final td in audioTestDataList) { + player.play(td.source); + // TODO(gustl22): Improve detection of started players via player + // state. + PlayerState? playerState; + await tester.waitFor( + () async { + if (player.state == PlayerState.completed || + player.state == PlayerState.disposed) { + playerState = player.state; + } else if (((await player.getCurrentPosition()) ?? + Duration.zero) > + Duration.zero) { + playerState = PlayerState.playing; + } + expect(playerState, isNotNull); + }, + ); + await player.stop(); + } + await player.dispose(); + }, + ); + }); + + group('Audio Context', () { + /// Android and iOS only: Play the same sound twice with a different audio + /// context each. This test can be executed on a device, with either + /// "Silent", "Vibrate" or "Ring" mode. In "Silent" or "Vibrate" mode + /// the second sound should not be audible. + testWidgets( + 'test changing AudioContextConfigs', + (WidgetTester tester) async { + final player = AudioPlayer(); + await player.setReleaseMode(ReleaseMode.stop); + + final td = wavUrl1TestData; + + var audioContext = AudioContextConfig( + //ignore: avoid_redundant_argument_values + route: AudioContextConfigRoute.system, + //ignore: avoid_redundant_argument_values + respectSilence: false, + ).build(); + await AudioPlayer.global.setAudioContext(audioContext); + await player.setAudioContext(audioContext); + + await player.play(td.source); + await expectLater(player.onPlayerComplete.first, completes); + + audioContext = AudioContextConfig( + //ignore: avoid_redundant_argument_values + route: AudioContextConfigRoute.system, + respectSilence: true, + ).build(); + await AudioPlayer.global.setAudioContext(audioContext); + await player.setAudioContext(audioContext); + + await player.resume(); + await expectLater(player.onPlayerComplete.first, completes); + await player.dispose(); + }, + + // FIXME: Causes media error on Android API 24 (min) + // PlatformException(AndroidAudioError, MEDIA_ERROR_UNKNOWN {what:1}, + // MEDIA_ERROR_UNKNOWN {extra:-19}, null) + // FIXME: [FLAKY] Audio Source sometimes does not play the second time on + // Android Exo, despite resume event is triggered. + skip: !features.hasRespectSilence || isAndroid, + ); + + testWidgets( + 'Set global AudioContextConfig on unsupported platforms', + (WidgetTester tester) async { + final audioContext = AudioContextConfig().build(); + final globalLogFuture = AudioPlayer.global.onLog.first; + await AudioPlayer.global.setAudioContext(audioContext); + + expect( + await globalLogFuture, + contains('Setting AudioContext is not supported'), + ); + + final player = AudioPlayer(); + final logFuture = player.onLog.first; + await player.setAudioContext(audioContext); + expect( + await logFuture, + contains('Setting AudioContext is not supported'), + ); + + await player.dispose(); + }, + skip: features.hasRespectSilence, + ); + + /// Android and iOS only: Play the same sound twice with a different audio + /// context each. This test can be executed on a device, with either + /// "Silent", "Vibrate" or "Ring" mode. In "Silent" or "Vibrate" mode + /// the second sound should not be audible. + testWidgets( + 'test changing AudioContextConfigs in LOW_LATENCY mode', + (WidgetTester tester) async { + final player = AudioPlayer(); + await player.setReleaseMode(ReleaseMode.stop); + player.setPlayerMode(PlayerMode.lowLatency); + + final td = wavUrl1TestData; + + var audioContext = AudioContextConfig( + //ignore: avoid_redundant_argument_values + route: AudioContextConfigRoute.system, + //ignore: avoid_redundant_argument_values + respectSilence: false, + ).build(); + await AudioPlayer.global.setAudioContext(audioContext); + await player.setAudioContext(audioContext); + + await player.play(td.source); + // Low latency mode does not emit a complete event + await tester.pumpPlatform( + (td.duration ?? Duration.zero) + const Duration(seconds: 8), + ); + expect(player.state, PlayerState.playing); + await player.stop(); + expect(player.state, PlayerState.stopped); + + audioContext = AudioContextConfig( + //ignore: avoid_redundant_argument_values + route: AudioContextConfigRoute.system, + respectSilence: true, + ).build(); + await AudioPlayer.global.setAudioContext(audioContext); + await player.setAudioContext(audioContext); + + await player.resume(); + // Low latency mode does not emit a complete event + await tester.pumpPlatform( + (td.duration ?? Duration.zero) + const Duration(seconds: 8), + ); + expect(player.state, PlayerState.playing); + await player.stop(); + expect(player.state, PlayerState.stopped); + + await player.dispose(); + }, + skip: !features.hasRespectSilence || !features.hasLowLatency, + ); + }); + + testWidgets('Race condition on play and pause (#1687)', + (WidgetTester tester) async { + final player = AudioPlayer(); + + final futurePlay = player.play(mp3Url1TestData.source); + + // Player is still in `stopped` state as it isn't playing yet. + expect(player.state, PlayerState.stopped); + expect(player.desiredState, PlayerState.playing); + + // Execute `pause` before `play` has finished. + final futurePause = player.pause(); + expect(player.desiredState, PlayerState.paused); + + await futurePlay; + await futurePause; + + expect(player.state, PlayerState.paused); + + await player.dispose(); + }); + + group( + 'Android only:', + () { + /// The test is auditory only! + /// It will succeed even if the wrong source is played. + testWidgets('Released wrong source on LOW_LATENCY (#1672)', + (WidgetTester tester) async { + var player = AudioPlayer() + ..setPlayerMode(PlayerMode.lowLatency) + ..setReleaseMode(ReleaseMode.stop); + + await player.play(wavAsset1TestData.source); + await tester.pumpPlatform(const Duration(seconds: 1)); + await player.stop(); + + await player.play(wavAsset2TestData.source); + await tester.pumpPlatform(const Duration(seconds: 1)); + await player.stop(); + + player = AudioPlayer() + ..setPlayerMode(PlayerMode.lowLatency) + ..setReleaseMode(ReleaseMode.stop); + + // This should play the new source, not the old one: + await player.play(wavAsset1TestData.source); + await tester.pumpPlatform(const Duration(seconds: 1)); + await player.stop(); + + await player.play(wavAsset2TestData.source); + await tester.pumpPlatform(const Duration(seconds: 1)); + await player.stop(); + }); + }, + skip: !features.hasLowLatency, + ); +} diff --git a/packages/audioplayers/example/integration_test/platform_features.dart b/packages/audioplayers/example/integration_test/platform_features.dart new file mode 100644 index 000000000..75d754001 --- /dev/null +++ b/packages/audioplayers/example/integration_test/platform_features.dart @@ -0,0 +1,162 @@ +import 'package:flutter/foundation.dart'; + +const testFeatureBytesSource = bool.fromEnvironment( + 'TEST_FEATURE_BYTES_SOURCE', + defaultValue: true, +); + +const testFeaturePlaybackRate = bool.fromEnvironment( + 'TEST_FEATURE_PLAYBACK_RATE', + defaultValue: true, +); + +const testFeatureLowLatency = bool.fromEnvironment( + 'TEST_FEATURE_LOW_LATENCY', + defaultValue: true, +); + +const testIsAndroidMediaPlayer = bool.fromEnvironment( + 'TEST_ANDROID_MEDIAPLAYER', +); + +/// Specify supported features for a platform. +class PlatformFeatures { + static const webPlatformFeatures = PlatformFeatures( + hasPlaylistSourceType: false, + hasLowLatency: false, + hasForceSpeaker: false, + hasDuckAudio: false, + hasRespectSilence: false, + hasStayAwake: false, + hasRecordingActive: false, + hasPlayingRoute: false, + hasErrorEvent: false, + ); + + static const androidPlatformFeatures = PlatformFeatures( + hasRecordingActive: false, + // ignore: avoid_redundant_argument_values + hasBytesSource: testFeatureBytesSource, + // ignore: avoid_redundant_argument_values + hasPlaybackRate: testFeaturePlaybackRate, + // ignore: avoid_redundant_argument_values + hasLowLatency: testFeatureLowLatency, + ); + + static const iosPlatformFeatures = PlatformFeatures( + hasDataUriSource: false, + hasBytesSource: false, + hasPlaylistSourceType: false, + hasLowLatency: false, + hasBalance: false, + ); + + static const macPlatformFeatures = PlatformFeatures( + hasDataUriSource: false, + hasBytesSource: false, + hasPlaylistSourceType: false, + hasLowLatency: false, + hasForceSpeaker: false, + hasDuckAudio: false, + hasRespectSilence: false, + hasStayAwake: false, + hasRecordingActive: false, + hasPlayingRoute: false, + hasBalance: false, + ); + + static const linuxPlatformFeatures = PlatformFeatures( + hasDataUriSource: false, + hasBytesSource: false, + hasLowLatency: false, + // MP3 duration is estimated: https://bugzilla.gnome.org/show_bug.cgi?id=726144 + // Use GstDiscoverer to get duration before playing: https://gstreamer.freedesktop.org/documentation/pbutils/gstdiscoverer.html?gi-language=c + hasMp3Duration: false, + hasForceSpeaker: false, + hasDuckAudio: false, + hasRespectSilence: false, + hasStayAwake: false, + hasRecordingActive: false, + hasPlayingRoute: false, + ); + + static const windowsPlatformFeatures = PlatformFeatures( + hasDataUriSource: false, + hasPlaylistSourceType: false, + hasLowLatency: false, + hasForceSpeaker: false, + hasDuckAudio: false, + hasRespectSilence: false, + hasStayAwake: false, + hasRecordingActive: false, + hasPlayingRoute: false, + ); + + final bool hasUrlSource; + final bool hasDataUriSource; + final bool hasAssetSource; + final bool hasBytesSource; + + final bool hasPlaylistSourceType; + + final bool hasLowLatency; + final bool hasReleaseModeRelease; + final bool hasReleaseModeLoop; + final bool hasVolume; + final bool hasBalance; + final bool hasSeek; + final bool hasMp3Duration; + + final bool hasPlaybackRate; + final bool hasForceSpeaker; // Not yet tested + final bool hasDuckAudio; // Not yet tested + final bool hasRespectSilence; + final bool hasStayAwake; // Not yet tested + final bool hasRecordingActive; // Not yet tested + final bool hasPlayingRoute; // Not yet tested + + final bool hasDurationEvent; + final bool hasPlayerStateEvent; + final bool hasErrorEvent; // Not yet tested + + const PlatformFeatures({ + this.hasUrlSource = true, + this.hasDataUriSource = true, + this.hasAssetSource = true, + this.hasBytesSource = true, + this.hasPlaylistSourceType = true, + this.hasLowLatency = true, + this.hasReleaseModeRelease = true, + this.hasReleaseModeLoop = true, + this.hasMp3Duration = true, + this.hasVolume = true, + this.hasBalance = true, + this.hasSeek = true, + this.hasPlaybackRate = true, + this.hasForceSpeaker = true, + this.hasDuckAudio = true, + this.hasRespectSilence = true, + this.hasStayAwake = true, + this.hasRecordingActive = true, + this.hasPlayingRoute = true, + this.hasDurationEvent = true, + this.hasPlayerStateEvent = true, + this.hasErrorEvent = true, + }); + + factory PlatformFeatures.instance() { + return kIsWeb + ? webPlatformFeatures + : defaultTargetPlatform == TargetPlatform.android + ? androidPlatformFeatures + : defaultTargetPlatform == TargetPlatform.iOS + ? iosPlatformFeatures + : defaultTargetPlatform == TargetPlatform.macOS + ? macPlatformFeatures + : defaultTargetPlatform == TargetPlatform.linux + ? linuxPlatformFeatures + : defaultTargetPlatform == TargetPlatform.windows + ? windowsPlatformFeatures + : const PlatformFeatures(); + } +} diff --git a/packages/audioplayers/example/integration_test/platform_test.dart b/packages/audioplayers/example/integration_test/platform_test.dart new file mode 100644 index 000000000..e0e373ba7 --- /dev/null +++ b/packages/audioplayers/example/integration_test/platform_test.dart @@ -0,0 +1,549 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'lib/lib_source_test_data.dart'; +import 'platform_features.dart'; +import 'source_test_data.dart'; +import 'test_utils.dart'; + +const _defaultTimeout = Duration(seconds: 30); + +final isLinux = !kIsWeb && defaultTargetPlatform == TargetPlatform.linux; +final isAndroid = !kIsWeb && defaultTargetPlatform == TargetPlatform.android; + +bool canDetermineDuration(SourceTestData td) { + // TODO(gustl22): cannot determine duration for VBR on Linux + // FIXME(gustl22): duration event is not emitted for short duration + // WAV on Linux (only platform tests, may be a race condition). + if (td.duration == null) { + return true; + } + if (isLinux) { + return !(td.isVBR || td.duration! < const Duration(seconds: 5)); + } + return true; +} + +void main() async { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final features = PlatformFeatures.instance(); + final audioTestDataList = await getAudioTestDataList(); + + group('Platform method channel', () { + late AudioplayersPlatformInterface platform; + late String playerId; + late Stream eventStream; + + setUp(() async { + platform = AudioplayersPlatformInterface.instance; + playerId = 'somePlayerId'; + await platform.create(playerId); + eventStream = platform.getEventStream(playerId); + }); + + tearDown(() async { + // Tear down is executed AFTER all expectations are fullfilled + await platform.dispose(playerId); + }); + + testWidgets( + 'Throw PlatformException, when loading invalid file', + (tester) async { + // Throws PlatformException instead of returning prepared event. + await tester.expectSettingSourceFailure( + future: tester.prepareSource( + playerId: playerId, + platform: platform, + testData: invalidAssetTestData, + ), + ); + + if (isLinux) { + // Linux throws a second failure event for invalid files. + // If not caught, it would be randomly thrown in the following tests. + final nextEvent = platform.getEventStream(playerId).first; + await tester.expectSettingSourceFailure(future: nextEvent); + } + }, + ); + + testWidgets( + 'Throw PlatformException, when loading non existent file', + (tester) async { + // Throws PlatformException instead of returning prepared event. + await tester.expectSettingSourceFailure( + future: tester.prepareSource( + playerId: playerId, + platform: platform, + testData: nonExistentUrlTestData, + ), + ); + }, + // FIXME(Gustl22): for some reason, the error propagated back from the + // Android MediaPlayer is only triggered, when the timeout has reached, + // although the error is emitted immediately. + // Further, the other future is not fulfilled and then mysteriously + // failing in later tests. + // The feature works with audioplayers_android_exo. + skip: testIsAndroidMediaPlayer, + ); + + testWidgets('#create and #dispose', (tester) async { + await platform.dispose(playerId); + + try { + // Call method after player has been released should throw a + // PlatformException + await platform.stop(playerId); + fail('PlatformException not thrown'); + } on PlatformException catch (e) { + expect( + e.message, + 'Player has not yet been created or has already been disposed.', + ); + } + + // Create player again, so it can be disposed in tearDown + await platform.create(playerId); + }); + + for (final td in audioTestDataList) { + testWidgets( + '#setSource #getPosition and #getDuration ${td.source}', + (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + if (!td.isLiveStream) { + // Live stream position is not aligned yet. + expect(await platform.getCurrentPosition(playerId), 0); + } + final durationMs = await platform.getDuration(playerId); + expect( + durationMs != null ? Duration(milliseconds: durationMs) : null, + // TODO(gustl22): once duration is always null for streams, + // then can remove fallback for Duration.zero + (Duration? actual) => durationRangeMatcher( + actual ?? Duration.zero, + td.duration ?? Duration.zero, + deviation: Duration(milliseconds: td.isVBR ? 100 : 1), + ), + ); + }, + // FIXME(gustl22): determines wrong initial position for m3u8 on Linux + skip: !canDetermineDuration(td) || + isLinux && td.source == m3u8UrlTestData.source, + ); + } + + if (features.hasVolume) { + for (final td in audioTestDataList) { + testWidgets('#volume ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + for (final volume in [0.0, 0.5, 1.0]) { + await platform.setVolume(playerId, volume); + await platform.resume(playerId); + await tester.pump(const Duration(seconds: 1)); + await platform.stop(playerId); + } + // May check native volume here + }); + } + } + + if (features.hasBalance) { + for (final td in audioTestDataList) { + testWidgets('#balance ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + for (final balance in [-1.0, 0.0, 1.0]) { + await platform.setBalance(playerId, balance); + await platform.resume(playerId); + await tester.pump(const Duration(seconds: 1)); + await platform.stop(playerId); + } + // May check native balance here + }); + } + } + + for (final td in audioTestDataList) { + if (features.hasPlaybackRate && !td.isLiveStream) { + testWidgets('#playbackRate ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + for (final playbackRate in [0.5, 1.0, 2.0]) { + await platform.setPlaybackRate(playerId, playbackRate); + await platform.resume(playerId); + await tester.pump(const Duration(seconds: 1)); + await platform.stop(playerId); + } + // May check native playback rate here + }); + } + } + + testWidgets('Avoid resume on setting playbackRate (#468)', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: mp3Url1TestData, + ); + await platform.setPlaybackRate(playerId, 2.0); + await tester.pumpAndSettle(const Duration(seconds: 2)); + expect(await platform.getCurrentPosition(playerId), 0); + }); + + for (final td in audioTestDataList) { + if (features.hasSeek && !td.isLiveStream) { + testWidgets('#seek with millisecond precision ${td.source}', + (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + + final eventStream = platform.getEventStream(playerId); + final seekCompleter = Completer(); + final onSeekSub = eventStream + .where((event) => event.eventType == AudioEventType.seekComplete) + .listen( + (_) => seekCompleter.complete(), + onError: seekCompleter.completeError, + ); + await platform.seek(playerId, const Duration(milliseconds: 22)); + await seekCompleter.future.timeout(_defaultTimeout); + await onSeekSub.cancel(); + final positionMs = await platform.getCurrentPosition(playerId); + expect( + positionMs != null ? Duration(milliseconds: positionMs) : null, + (Duration? actual) => durationRangeMatcher( + actual, + const Duration(milliseconds: 22), + deviation: const Duration(milliseconds: 1), + ), + ); + }); + } + } + + for (final td in audioTestDataList) { + if (features.hasReleaseModeLoop && + !td.isLiveStream && + td.duration! < const Duration(seconds: 2)) { + testWidgets('#ReleaseMode.loop ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + await platform.setReleaseMode(playerId, ReleaseMode.loop); + await platform.resume(playerId); + await tester.pump(const Duration(seconds: 3)); + await platform.stop(playerId); + + // May check number of loops here + }); + } + } + + for (final td in audioTestDataList) { + if (features.hasReleaseModeRelease && !td.isLiveStream) { + testWidgets('#ReleaseMode.release ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + await platform.setReleaseMode(playerId, ReleaseMode.release); + await platform.resume(playerId); + if (td.duration! < const Duration(seconds: 2)) { + await tester.pumpAndSettle(const Duration(seconds: 3)); + // No need to call stop, as it should be released by now + } else { + await tester.pumpAndSettle(const Duration(seconds: 1)); + await platform.stop(playerId); + } + // TODO(Gustl22): test if source was released + expect(await platform.getDuration(playerId), null); + expect(await platform.getCurrentPosition(playerId), null); + }); + } + } + + for (final td in audioTestDataList) { + testWidgets('#release ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + await tester.pump(const Duration(seconds: 1)); + await platform.release(playerId); + // TODO(Gustl22): test if source was released + // Check if position & duration is zero after play & release + expect(await platform.getDuration(playerId), null); + expect(await platform.getCurrentPosition(playerId), null); + }); + } + + testWidgets('Set same source twice (#1520)', (tester) async { + final td = wavUrl1TestData; + for (var i = 0; i < 2; i++) { + if (i == 0) { + // We don't expect the duration event is emitted again, + // if the same source is set twice + tester.expectDurationInStream( + eventStream, + (Duration? actual) => actual != null, + ); + } + + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + } + }); + }); + + group('Platform event channel', () { + late AudioplayersPlatformInterface platform; + late String playerId; + late Stream eventStream; + + setUp(() async { + platform = AudioplayersPlatformInterface.instance; + playerId = 'somePlayerId'; + await platform.create(playerId); + eventStream = platform.getEventStream(playerId); + }); + + tearDown(() async { + // Tear down is executed AFTER all expectations are fullfilled + await platform.dispose(playerId); + }); + + for (final td in audioTestDataList) { + if (features.hasDurationEvent && !td.isLiveStream) { + testWidgets( + '#durationEvent ${td.source}', + (tester) async { + // Wait for duration before event is emitted. + tester.expectDurationInStream( + eventStream, + (Duration? actual) => durationRangeMatcher( + actual, + td.duration, + deviation: Duration( + milliseconds: td.isVBR || isWindows ? 100 : 1, + ), + ), + ); + + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + }, + skip: !canDetermineDuration(td), + ); + } + } + + for (final td in audioTestDataList) { + if (!td.isLiveStream && td.duration! < const Duration(seconds: 2)) { + testWidgets('#completeEvent ${td.source}', (tester) async { + await tester.prepareSource( + playerId: playerId, + platform: platform, + testData: td, + ); + + expect( + eventStream.map((event) => event.eventType), + emitsThrough(AudioEventType.complete), + ); + + await platform.resume(playerId); + await tester.pumpAndSettle(const Duration(seconds: 3)); + }); + } + } + + testWidgets('Listen and cancel twice', (tester) async { + final eventStream = platform.getEventStream(playerId); + for (var i = 0; i < 2; i++) { + final eventSub = eventStream.listen(null); + await eventSub.cancel(); + } + }); + + testWidgets('Emit platform log', (tester) async { + final eventStream = platform.getEventStream(playerId); + expect( + eventStream, + emitsThrough( + const AudioEvent( + eventType: AudioEventType.log, + logMessage: 'SomeLog', + ), + ), + ); + await platform.emitLog(playerId, 'SomeLog'); + }); + + testWidgets('Emit global platform log', (tester) async { + final global = GlobalAudioplayersPlatformInterface.instance; + + final globalEventStream = global.getGlobalEventStream(); + expect( + globalEventStream, + emitsThrough( + const GlobalAudioEvent( + eventType: GlobalAudioEventType.log, + logMessage: 'SomeGlobalLog', + ), + ), + ); + + await global.emitGlobalLog('SomeGlobalLog'); + }); + + testWidgets('Emit platform error', (tester) async { + final eventStream = platform.getEventStream(playerId); + expect( + eventStream, + emitsThrough( + emitsError( + isA() + .having( + (PlatformException e) => e.code, + 'code', + 'SomeErrorCode', + ) + .having( + (PlatformException e) => e.message, + 'message', + 'SomeErrorMessage', + ), + ), + ), + ); + + await platform.emitError( + playerId, + 'SomeErrorCode', + 'SomeErrorMessage', + ); + }); + + testWidgets('Emit global platform error', (tester) async { + final global = GlobalAudioplayersPlatformInterface.instance; + final globalEventStream = global.getGlobalEventStream(); + expect( + globalEventStream, + emitsThrough( + emitsError( + isA() + .having( + (PlatformException e) => e.code, + 'code', + 'SomeGlobalErrorCode', + ) + .having( + (PlatformException e) => e.message, + 'message', + 'SomeGlobalErrorMessage', + ), + ), + ), + ); + + await global.emitGlobalError( + 'SomeGlobalErrorCode', + 'SomeGlobalErrorMessage', + ); + }); + }); +} + +extension on WidgetTester { + Future prepareSource({ + required String playerId, + required AudioplayersPlatformInterface platform, + required LibSourceTestData testData, + }) async { + final eventStream = platform.getEventStream(playerId); + final preparedFuture = eventStream + .firstWhere( + (event) => + event.eventType == AudioEventType.prepared && + (event.isPrepared ?? false), + ) + .timeout(_defaultTimeout); + + Future setSource(Source source) async { + if (source is UrlSource) { + return platform.setSourceUrl(playerId, source.url); + } else if (source is AssetSource) { + final cachePath = await AudioCache.instance.loadPath(source.path); + return platform.setSourceUrl(playerId, cachePath, isLocal: true); + } else if (source is BytesSource) { + return platform.setSourceBytes(playerId, source.bytes); + } else { + throw 'Unknown source type: ${source.runtimeType}'; + } + } + + // Need to await the setting the source to propagate immediate errors. + final setSourceFuture = setSource(testData.source); + + // Wait simultaneously to ensure all errors are propagated through the same + // future. + await Future.wait([setSourceFuture, preparedFuture]); + } + + void expectDurationInStream(Stream eventStream, dynamic matcher) { + expect( + eventStream, + emitsThrough( + isA() + .having((e) => e.eventType, 'eventType', AudioEventType.duration) + .having((e) => e.duration, 'duration', matcher), + ), + ); + } + + Future expectSettingSourceFailure({ + required Future future, + }) async { + try { + await future; + fail('PlatformException not thrown'); + } on PlatformException catch (e) { + expect(e.message, startsWith('Failed to set source.')); + } + } +} diff --git a/packages/audioplayers/example/integration_test/source_test_data.dart b/packages/audioplayers/example/integration_test/source_test_data.dart new file mode 100644 index 000000000..eb49a8606 --- /dev/null +++ b/packages/audioplayers/example/integration_test/source_test_data.dart @@ -0,0 +1,22 @@ +/// Data of a ui test source. +abstract class SourceTestData { + Duration? duration; + + bool get isLiveStream => duration == null; + + /// Whether this source has variable bitrate + bool isVBR; + + SourceTestData({ + required this.duration, + this.isVBR = false, + }); + + @override + String toString() { + return 'SourceTestData(' + 'duration: $duration, ' + 'isVBR: $isVBR' + ')'; + } +} diff --git a/packages/audioplayers/example/integration_test/test_utils.dart b/packages/audioplayers/example/integration_test/test_utils.dart new file mode 100644 index 000000000..90f9ef0e7 --- /dev/null +++ b/packages/audioplayers/example/integration_test/test_utils.dart @@ -0,0 +1,103 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +void printWithTimeOnFailure(String message) { + printOnFailure('${DateTime.now()}: $message'); +} + +bool durationRangeMatcher( + Duration? actual, + Duration? expected, { + Duration deviation = const Duration(seconds: 1), +}) { + if (actual == null && expected == null) { + return true; + } + if (actual == null || expected == null) { + return false; + } + return actual >= (expected - deviation) && actual <= (expected + deviation); +} + +extension ExtendedWidgetTester on WidgetTester { + // Add [stackTrace] to work around https://github.com/flutter/flutter/issues/89138 + Future waitFor( + Future Function() testExpectation, { + Duration? timeout = const Duration(seconds: 15), + Duration? pollInterval = const Duration(milliseconds: 500), + String? stackTrace, + }) async => + _waitUntil( + (setFailureMessage) async { + try { + await pump(); + await testExpectation(); + return true; + } on TestFailure catch (e) { + setFailureMessage(e.message ?? ''); + return false; + } + }, + timeout: timeout, + pollInterval: pollInterval, + stackTrace: stackTrace, + ); + + /// Waits until the [condition] returns true + /// Will raise a complete with a [TimeoutException] if the + /// condition does not return true with the timeout period. + /// Copied from: https://github.com/jonsamwell/flutter_gherkin/blob/02a4af91d7a2512e0a4540b9b1ab13e36d5c6f37/lib/src/flutter/utils/driver_utils.dart#L86 + Future _waitUntil( + Future Function(void Function(String message) setFailureMessage) + condition, { + Duration? timeout = const Duration(seconds: 15), + Duration? pollInterval = const Duration(milliseconds: 500), + String? stackTrace, + }) async { + var firstFailureMsg = ''; + var lastFailureMsg = 'same as first failure'; + void setFailureMessage(String message) { + if (firstFailureMsg.isEmpty) { + firstFailureMsg = '${DateTime.now()}:\n $message'; + } else { + lastFailureMsg = '${DateTime.now()}:\n $message'; + } + } + + try { + await Future.microtask( + () async { + final completer = Completer(); + final maxAttempts = + (timeout!.inMilliseconds / pollInterval!.inMilliseconds).round(); + var attempts = 0; + + while (attempts < maxAttempts) { + final result = await condition(setFailureMessage); + if (result) { + completer.complete(); + break; + } else { + await Future.delayed(pollInterval); + } + attempts++; + } + }, + ).timeout( + timeout!, + ); + } on TimeoutException catch (e) { + throw Exception( + '''$e + +Stacktrace: +$stackTrace +First Failure: +$firstFailureMsg +Last Failure: +$lastFailureMsg''', + ); + } + } +} diff --git a/packages/audioplayers/example/ios/.gitignore b/packages/audioplayers/example/ios/.gitignore index e96ef602b..7a7f9873a 100644 --- a/packages/audioplayers/example/ios/.gitignore +++ b/packages/audioplayers/example/ios/.gitignore @@ -1,3 +1,4 @@ +**/dgph *.mode1v3 *.mode2v3 *.moved-aside @@ -18,6 +19,7 @@ Flutter/App.framework Flutter/Flutter.framework Flutter/Flutter.podspec Flutter/Generated.xcconfig +Flutter/ephemeral/ Flutter/app.flx Flutter/app.zip Flutter/flutter_assets/ diff --git a/packages/audioplayers/example/ios/Flutter/AppFrameworkInfo.plist b/packages/audioplayers/example/ios/Flutter/AppFrameworkInfo.plist index f2872cf47..1dc6cf765 100644 --- a/packages/audioplayers/example/ios/Flutter/AppFrameworkInfo.plist +++ b/packages/audioplayers/example/ios/Flutter/AppFrameworkInfo.plist @@ -3,7 +3,7 @@ CFBundleDevelopmentRegion - $(DEVELOPMENT_LANGUAGE) + en CFBundleExecutable App CFBundleIdentifier @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 9.0 + 13.0 diff --git a/packages/audioplayers/example/ios/Flutter/Debug.xcconfig b/packages/audioplayers/example/ios/Flutter/Debug.xcconfig index e8efba114..ec97fc6f3 100644 --- a/packages/audioplayers/example/ios/Flutter/Debug.xcconfig +++ b/packages/audioplayers/example/ios/Flutter/Debug.xcconfig @@ -1,2 +1,2 @@ -#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/packages/audioplayers/example/ios/Flutter/Release.xcconfig b/packages/audioplayers/example/ios/Flutter/Release.xcconfig index 399e9340e..c4855bfe2 100644 --- a/packages/audioplayers/example/ios/Flutter/Release.xcconfig +++ b/packages/audioplayers/example/ios/Flutter/Release.xcconfig @@ -1,2 +1,2 @@ -#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/packages/audioplayers/example/ios/Podfile b/packages/audioplayers/example/ios/Podfile index 1e8c3c90a..e51a31d9c 100644 --- a/packages/audioplayers/example/ios/Podfile +++ b/packages/audioplayers/example/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '9.0' +# platform :ios, '13.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -32,6 +32,9 @@ target 'Runner' do use_modular_headers! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/audioplayers/example/ios/Podfile.lock b/packages/audioplayers/example/ios/Podfile.lock index 920e35010..c3dcb254e 100644 --- a/packages/audioplayers/example/ios/Podfile.lock +++ b/packages/audioplayers/example/ios/Podfile.lock @@ -1,28 +1,16 @@ PODS: - - audioplayers (0.0.1): - - Flutter - Flutter (1.0.0) - - path_provider (0.0.1): - - Flutter DEPENDENCIES: - - audioplayers (from `.symlinks/plugins/audioplayers/ios`) - Flutter (from `Flutter`) - - path_provider (from `.symlinks/plugins/path_provider/ios`) EXTERNAL SOURCES: - audioplayers: - :path: ".symlinks/plugins/audioplayers/ios" Flutter: :path: Flutter - path_provider: - :path: ".symlinks/plugins/path_provider/ios" SPEC CHECKSUMS: - audioplayers: 455322b54050b30ea4b1af7cd9e9d105f74efa8c - Flutter: 50d75fe2f02b26cc09d224853bb45737f8b3214a - path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c + Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 -PODFILE CHECKSUM: aafe91acc616949ddb318b77800a7f51bffa2a4c +PODFILE CHECKSUM: 4f1c12611da7338d21589c0b2ecd6bd20b109694 -COCOAPODS: 1.10.1 +COCOAPODS: 1.16.2 diff --git a/packages/audioplayers/example/ios/Runner.xcodeproj/project.pbxproj b/packages/audioplayers/example/ios/Runner.xcodeproj/project.pbxproj index fada7b6fc..e9764f5d2 100644 --- a/packages/audioplayers/example/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/audioplayers/example/ios/Runner.xcodeproj/project.pbxproj @@ -3,19 +3,32 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; - 4DFF211A4FC910F5AF15E5D4 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 30D1B06B948ECC0137AADFD0 /* Pods_Runner.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 7AF5CB40935CF80CD8857EE0 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4B905555E7CCD50852E96567 /* Pods_Runner.framework */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + D4DA72BBBEF28B9235346586 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F5EF827304CB63B79FE8F8FE /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 97C146ED1CF9000F007C117D; + remoteInfo = Runner; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXCopyFilesBuildPhase section */ 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; @@ -30,14 +43,20 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 095D4AE74CAFEA857ED63C9E /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 30D1B06B948ECC0137AADFD0 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 340C68AD309A763AFB2243F5 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 65B31CC23322DCCFCFCD7334 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 4AA99D9EAC764A382DD7CBE3 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 4B905555E7CCD50852E96567 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 69818CF55F90EE8A0FD854F7 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 86FB9DDAEB521E31794CD75E /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -45,43 +64,67 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - BA2B0E854C65B064A64AA311 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - BCF181AD032432EDD96A8337 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + C980D7A59389E2C04141179A /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + F5EF827304CB63B79FE8F8FE /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 07F4B0920180568D66C19349 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D4DA72BBBEF28B9235346586 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 4DFF211A4FC910F5AF15E5D4 /* Pods_Runner.framework in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 7AF5CB40935CF80CD8857EE0 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 4C2CE7D0C5D51F695F2E7AC4 /* Pods */ = { + 106C1C12C34053F62D5D2826 /* Frameworks */ = { isa = PBXGroup; children = ( - 65B31CC23322DCCFCFCD7334 /* Pods-Runner.debug.xcconfig */, - BA2B0E854C65B064A64AA311 /* Pods-Runner.release.xcconfig */, - BCF181AD032432EDD96A8337 /* Pods-Runner.profile.xcconfig */, + 4B905555E7CCD50852E96567 /* Pods_Runner.framework */, + F5EF827304CB63B79FE8F8FE /* Pods_RunnerTests.framework */, ); - path = Pods; + name = Frameworks; sourceTree = ""; }; - 756E040242E3E8464CD8EC6D /* Frameworks */ = { + 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( - 30D1B06B948ECC0137AADFD0 /* Pods_Runner.framework */, + 331C807B294A618700263BE5 /* RunnerTests.swift */, ); - name = Frameworks; + path = RunnerTests; + sourceTree = ""; + }; + 66FF32B2EF1232A59551B15C /* Pods */ = { + isa = PBXGroup; + children = ( + 340C68AD309A763AFB2243F5 /* Pods-Runner.debug.xcconfig */, + C980D7A59389E2C04141179A /* Pods-Runner.release.xcconfig */, + 69818CF55F90EE8A0FD854F7 /* Pods-Runner.profile.xcconfig */, + 4AA99D9EAC764A382DD7CBE3 /* Pods-RunnerTests.debug.xcconfig */, + 86FB9DDAEB521E31794CD75E /* Pods-RunnerTests.release.xcconfig */, + 095D4AE74CAFEA857ED63C9E /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; sourceTree = ""; }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, 9740EEB21CF90195004384FC /* Debug.xcconfig */, 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, @@ -96,8 +139,9 @@ 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, - 4C2CE7D0C5D51F695F2E7AC4 /* Pods */, - 756E040242E3E8464CD8EC6D /* Frameworks */, + 331C8082294A63A400263BE5 /* RunnerTests */, + 66FF32B2EF1232A59551B15C /* Pods */, + 106C1C12C34053F62D5D2826 /* Frameworks */, ); sourceTree = ""; }; @@ -105,6 +149,7 @@ isa = PBXGroup; children = ( 97C146EE1CF9000F007C117D /* Runner.app */, + 331C8081294A63A400263BE5 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -127,24 +172,45 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 331C8080294A63A400263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 8CDAEAED9C3E1CCF7AE35E7C /* [CP] Check Pods Manifest.lock */, + 331C807D294A63A400263BE5 /* Sources */, + 331C807F294A63A400263BE5 /* Resources */, + 07F4B0920180568D66C19349 /* Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 331C8086294A63A400263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 7C74FF8B0F849ADA3911FA7A /* [CP] Check Pods Manifest.lock */, + EC665F26E58AEE7E06E969EC /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 40242DC6B5A22D68021F4453 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); dependencies = ( ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 97C146EE1CF9000F007C117D /* Runner.app */; productType = "com.apple.product-type.application"; @@ -155,9 +221,14 @@ 97C146E61CF9000F007C117D /* Project object */ = { isa = PBXProject; attributes = { - LastUpgradeCheck = 1020; + BuildIndependentTargetsInParallel = YES; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { + 331C8080294A63A400263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 97C146ED1CF9000F007C117D; + }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; @@ -173,16 +244,27 @@ Base, ); mainGroup = 97C146E51CF9000F007C117D; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 97C146EF1CF9000F007C117D /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 97C146ED1CF9000F007C117D /* Runner */, + 331C8080294A63A400263BE5 /* RunnerTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 331C807F294A63A400263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -199,10 +281,12 @@ /* Begin PBXShellScriptBuildPhase section */ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); name = "Thin Binary"; outputPaths = ( @@ -211,24 +295,44 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 40242DC6B5A22D68021F4453 /* [CP] Embed Pods Frameworks */ = { + 8CDAEAED9C3E1CCF7AE35E7C /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 7C74FF8B0F849ADA3911FA7A /* [CP] Check Pods Manifest.lock */ = { + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + EC665F26E58AEE7E06E969EC /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -250,23 +354,17 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { - isa = PBXShellScriptBuildPhase; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 331C807D294A63A400263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - ); - inputPaths = ( - ); - name = "Run Script"; - outputPaths = ( + 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; -/* End PBXShellScriptBuildPhase section */ - -/* Begin PBXSourcesBuildPhase section */ 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -278,6 +376,14 @@ }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 331C8086294A63A400263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 97C146ED1CF9000F007C117D /* Runner */; + targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin PBXVariantGroup section */ 97C146FA1CF9000F007C117D /* Main.storyboard */ = { isa = PBXVariantGroup; @@ -302,6 +408,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -331,6 +438,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -339,7 +447,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -355,22 +463,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = GX383SWQ53; ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); - PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.audioplayers.example; + PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -378,10 +477,61 @@ }; name = Profile; }; + 331C8088294A63A400263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 4AA99D9EAC764A382DD7CBE3 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Debug; + }; + 331C8089294A63A400263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 86FB9DDAEB521E31794CD75E /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Release; + }; + 331C808A294A63A400263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 095D4AE74CAFEA857ED63C9E /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -411,6 +561,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -425,7 +576,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -437,6 +588,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; CLANG_CXX_LIBRARY = "libc++"; @@ -466,6 +618,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -474,7 +627,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 9.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -492,22 +645,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = GX383SWQ53; ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); - PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.audioplayers.example; + PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -523,22 +667,13 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; - DEVELOPMENT_TEAM = GX383SWQ53; ENABLE_BITCODE = NO; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - LIBRARY_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter", - ); - PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.audioplayers.example; + PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.example; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -549,6 +684,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C8088294A63A400263BE5 /* Debug */, + 331C8089294A63A400263BE5 /* Release */, + 331C808A294A63A400263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -570,6 +715,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 97C146E61CF9000F007C117D /* Project object */; } diff --git a/packages/audioplayers/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/audioplayers/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..df0b497cf --- /dev/null +++ b/packages/audioplayers/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,60 @@ +{ + "originHash" : "e1022163c6069e02bac513626e0c5427806561a47fcd073bafcd8e7003958f24", + "pins" : [ + { + "identity" : "dkcamera", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKCamera", + "state" : { + "branch" : "master", + "revision" : "5c691d11014b910aff69f960475d70e65d9dcc96" + } + }, + { + "identity" : "dkimagepickercontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKImagePickerController", + "state" : { + "branch" : "4.3.9", + "revision" : "0bdfeacefa308545adde07bef86e349186335915" + } + }, + { + "identity" : "dkphotogallery", + "kind" : "remoteSourceControl", + "location" : "https://github.com/zhangao0086/DKPhotoGallery", + "state" : { + "branch" : "master", + "revision" : "311c1bc7a94f1538f82773a79c84374b12a2ef3d" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage", + "state" : { + "revision" : "34cf2423a2c4088d06a3b08655603b5bc3eeeb3a", + "version" : "5.21.2" + } + }, + { + "identity" : "swiftygif", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kirualex/SwiftyGif.git", + "state" : { + "revision" : "4430cbc148baa3907651d40562d96325426f409a", + "version" : "5.4.5" + } + }, + { + "identity" : "tocropviewcontroller", + "kind" : "remoteSourceControl", + "location" : "https://github.com/TimOliver/TOCropViewController", + "state" : { + "revision" : "a634cb7cdfd580006e79a6e74e64417fe9e9783b", + "version" : "2.7.4" + } + } + ], + "version" : 2 +} diff --git a/packages/audioplayers/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/audioplayers/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a28140cfd..95d6e55f2 100644 --- a/packages/audioplayers/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/audioplayers/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,10 +1,28 @@ + LastUpgradeVersion = "1510" + version = "1.7"> + + + + + + + + + + - - - - + + + + + + @@ -61,8 +91,6 @@ ReferencedContainer = "container:Runner.xcodeproj"> - - CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + AP Example CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -11,15 +13,22 @@ CFBundleInfoDictionaryVersion 6.0 CFBundleName - $(PRODUCT_NAME) + audioplayers_example CFBundlePackageType APPL CFBundleShortVersionString $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? CFBundleVersion $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + UILaunchStoryboardName LaunchScreen UIMainStoryboardFile @@ -37,7 +46,11 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + CADisableMinimumFrameDurationOnPhone + + UIApplicationSupportsIndirectInputEvents + UIViewControllerBasedStatusBarAppearance - + diff --git a/packages/audioplayers/example/ios/RunnerTests/RunnerTests.swift b/packages/audioplayers/example/ios/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000..86a7c3b1b --- /dev/null +++ b/packages/audioplayers/example/ios/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Flutter +import UIKit +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/audioplayers/example/lib/components/btn.dart b/packages/audioplayers/example/lib/components/btn.dart index 0e16f0900..238c4d46f 100644 --- a/packages/audioplayers/example/lib/components/btn.dart +++ b/packages/audioplayers/example/lib/components/btn.dart @@ -4,14 +4,21 @@ class Btn extends StatelessWidget { final String txt; final VoidCallback onPressed; - const Btn({Key? key, required this.txt, required this.onPressed}) - : super(key: key); + const Btn({ + required this.txt, + required this.onPressed, + super.key, + }); @override Widget build(BuildContext context) { - return ButtonTheme( - minWidth: 48.0, - child: ElevatedButton(child: Text(txt), onPressed: onPressed), + return Padding( + padding: const EdgeInsets.all(4), + child: ElevatedButton( + style: ElevatedButton.styleFrom(minimumSize: const Size(48, 36)), + onPressed: onPressed, + child: Text(txt), + ), ); } } diff --git a/packages/audioplayers/example/lib/components/cbx.dart b/packages/audioplayers/example/lib/components/cbx.dart new file mode 100644 index 000000000..65674a2a1 --- /dev/null +++ b/packages/audioplayers/example/lib/components/cbx.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class Cbx extends StatelessWidget { + final String label; + final bool value; + final void Function({required bool? value}) update; + + const Cbx( + this.label, + this.update, { + required this.value, + super.key, + }); + + @override + Widget build(BuildContext context) { + return CheckboxListTile( + title: Text(label), + value: value, + onChanged: (v) => update(value: v), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/dlg.dart b/packages/audioplayers/example/lib/components/dlg.dart new file mode 100644 index 000000000..fc9343bb2 --- /dev/null +++ b/packages/audioplayers/example/lib/components/dlg.dart @@ -0,0 +1,48 @@ +import 'package:audioplayers_example/components/btn.dart'; +import 'package:flutter/material.dart'; + +class SimpleDlg extends StatelessWidget { + final String message; + final String action; + + const SimpleDlg({ + required this.message, + required this.action, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Dlg( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text(message), + Btn( + txt: action, + onPressed: Navigator.of(context).pop, + ), + ], + ), + ); + } +} + +class Dlg extends StatelessWidget { + final Widget child; + + const Dlg({ + required this.child, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Dialog( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: child, + ), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/drop_down.dart b/packages/audioplayers/example/lib/components/drop_down.dart new file mode 100644 index 000000000..97f1f5e4c --- /dev/null +++ b/packages/audioplayers/example/lib/components/drop_down.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +class LabeledDropDown extends StatelessWidget { + final String label; + final Map options; + final T selected; + final void Function(T?) onChange; + + const LabeledDropDown({ + required this.label, + required this.options, + required this.selected, + required this.onChange, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(label), + trailing: CustomDropDown( + options: options, + selected: selected, + onChange: onChange, + ), + ); + } +} + +class CustomDropDown extends StatelessWidget { + final Map options; + final T selected; + final void Function(T?) onChange; + final bool isExpanded; + + const CustomDropDown({ + required this.options, + required this.selected, + required this.onChange, + this.isExpanded = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + return DropdownButton( + isExpanded: isExpanded, + value: selected, + onChanged: onChange, + items: options.entries + .map>( + (entry) => DropdownMenuItem( + value: entry.key, + child: Text(entry.value), + ), + ) + .toList(), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/list_tile.dart b/packages/audioplayers/example/lib/components/list_tile.dart new file mode 100644 index 000000000..b7dc5204b --- /dev/null +++ b/packages/audioplayers/example/lib/components/list_tile.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class WrappedListTile extends StatelessWidget { + final List children; + final Widget? leading; + final Widget? trailing; + + const WrappedListTile({ + required this.children, + this.leading, + this.trailing, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Wrap( + alignment: WrapAlignment.end, + children: children, + ), + leading: leading, + trailing: trailing, + ); + } +} diff --git a/packages/audioplayers/example/lib/components/pad.dart b/packages/audioplayers/example/lib/components/pad.dart new file mode 100644 index 000000000..2bc364874 --- /dev/null +++ b/packages/audioplayers/example/lib/components/pad.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +class Pad extends StatelessWidget { + final double width; + final double height; + + const Pad({super.key, this.width = 0, this.height = 0}); + + @override + Widget build(BuildContext context) { + return Container( + width: width, + height: height, + ); + } +} diff --git a/packages/audioplayers/example/lib/components/player_widget.dart b/packages/audioplayers/example/lib/components/player_widget.dart new file mode 100644 index 000000000..0994c05b4 --- /dev/null +++ b/packages/audioplayers/example/lib/components/player_widget.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; + +// This code is also used in the example.md. Please keep it up to date. +class PlayerWidget extends StatefulWidget { + final AudioPlayer player; + + const PlayerWidget({ + required this.player, + super.key, + }); + + @override + State createState() { + return _PlayerWidgetState(); + } +} + +class _PlayerWidgetState extends State { + PlayerState? _playerState; + Duration? _duration; + Duration? _position; + + StreamSubscription? _durationSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _playerCompleteSubscription; + StreamSubscription? _playerStateChangeSubscription; + + bool get _isPlaying => _playerState == PlayerState.playing; + + bool get _isPaused => _playerState == PlayerState.paused; + + String get _durationText => _duration?.toString().split('.').first ?? ''; + + String get _positionText => _position?.toString().split('.').first ?? ''; + + AudioPlayer get player => widget.player; + + @override + void initState() { + super.initState(); + // Use initial values from player + _playerState = player.state; + player.getDuration().then( + (value) => setState(() { + _duration = value; + }), + ); + player.getCurrentPosition().then( + (value) => setState(() { + _position = value; + }), + ); + _initStreams(); + } + + @override + void setState(VoidCallback fn) { + // Subscriptions only can be closed asynchronously, + // therefore events can occur after widget has been disposed. + if (mounted) { + super.setState(fn); + } + } + + @override + void dispose() { + _durationSubscription?.cancel(); + _positionSubscription?.cancel(); + _playerCompleteSubscription?.cancel(); + _playerStateChangeSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final color = Theme.of(context).primaryColor; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + key: const Key('play_button'), + onPressed: _isPlaying ? null : _play, + iconSize: 48.0, + icon: const Icon(Icons.play_arrow), + color: color, + ), + IconButton( + key: const Key('pause_button'), + onPressed: _isPlaying ? _pause : null, + iconSize: 48.0, + icon: const Icon(Icons.pause), + color: color, + ), + IconButton( + key: const Key('stop_button'), + onPressed: _isPlaying || _isPaused ? _stop : null, + iconSize: 48.0, + icon: const Icon(Icons.stop), + color: color, + ), + ], + ), + Slider( + onChanged: (value) { + final duration = _duration; + if (duration == null) { + return; + } + final position = value * duration.inMilliseconds; + player.seek(Duration(milliseconds: position.round())); + }, + value: (_position != null && + _duration != null && + _position!.inMilliseconds > 0 && + _position!.inMilliseconds < _duration!.inMilliseconds) + ? _position!.inMilliseconds / _duration!.inMilliseconds + : 0.0, + ), + Text( + _position != null + ? '$_positionText / $_durationText' + : _duration != null + ? _durationText + : '', + style: const TextStyle(fontSize: 16.0), + ), + ], + ); + } + + void _initStreams() { + _durationSubscription = player.onDurationChanged.listen((duration) { + setState(() => _duration = duration); + }); + + _positionSubscription = player.onPositionChanged.listen( + (p) => setState(() => _position = p), + ); + + _playerCompleteSubscription = player.onPlayerComplete.listen((event) { + setState(() { + _playerState = PlayerState.stopped; + _position = Duration.zero; + }); + }); + + _playerStateChangeSubscription = + player.onPlayerStateChanged.listen((state) { + setState(() { + _playerState = state; + }); + }); + } + + Future _play() async { + await player.resume(); + setState(() => _playerState = PlayerState.playing); + } + + Future _pause() async { + await player.pause(); + setState(() => _playerState = PlayerState.paused); + } + + Future _stop() async { + await player.stop(); + setState(() { + _playerState = PlayerState.stopped; + _position = Duration.zero; + }); + } +} diff --git a/packages/audioplayers/example/lib/components/properties_widget.dart b/packages/audioplayers/example/lib/components/properties_widget.dart new file mode 100644 index 000000000..37e4ddf13 --- /dev/null +++ b/packages/audioplayers/example/lib/components/properties_widget.dart @@ -0,0 +1,104 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_example/utils.dart'; +import 'package:flutter/material.dart'; + +class PropertiesWidget extends StatefulWidget { + final AudioPlayer player; + + const PropertiesWidget({ + required this.player, + super.key, + }); + + @override + State createState() => _PropertiesWidgetState(); +} + +class _PropertiesWidgetState extends State { + Future refresh() async { + setState(() {}); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: const Text('Properties'), + trailing: ElevatedButton.icon( + icon: const Icon(Icons.refresh), + key: const Key('refreshButton'), + label: const Text('Refresh'), + onPressed: refresh, + ), + ), + ListTile( + title: FutureBuilder( + future: widget.player.getDuration(), + builder: (context, snap) { + return Text( + snap.data?.toString() ?? '-', + key: const Key('durationText'), + ); + }, + ), + subtitle: const Text('Duration'), + leading: const Icon(Icons.timelapse), + ), + ListTile( + title: FutureBuilder( + future: widget.player.getCurrentPosition(), + builder: (context, snap) { + return Text( + snap.data?.toString() ?? '-', + key: const Key('positionText'), + ); + }, + ), + subtitle: const Text('Position'), + leading: const Icon(Icons.timer), + ), + ListTile( + title: Text( + widget.player.state.toString(), + key: const Key('playerStateText'), + ), + subtitle: const Text('State'), + leading: Icon(widget.player.state.getIcon()), + ), + ListTile( + title: Text( + widget.player.source?.toString() ?? '-', + key: const Key('sourceText'), + ), + subtitle: const Text('Source'), + leading: const Icon(Icons.audio_file), + ), + ListTile( + title: Text( + widget.player.volume.toString(), + key: const Key('volumeText'), + ), + subtitle: const Text('Volume'), + leading: const Icon(Icons.volume_up), + ), + ListTile( + title: Text( + widget.player.balance.toString(), + key: const Key('balanceText'), + ), + subtitle: const Text('Balance'), + leading: const Icon(Icons.balance), + ), + ListTile( + title: Text( + widget.player.playbackRate.toString(), + key: const Key('playbackRateText'), + ), + subtitle: const Text('Playback Rate'), + leading: const Icon(Icons.speed), + ), + ], + ); + } +} diff --git a/packages/audioplayers/example/lib/components/stream_widget.dart b/packages/audioplayers/example/lib/components/stream_widget.dart new file mode 100644 index 000000000..771585e27 --- /dev/null +++ b/packages/audioplayers/example/lib/components/stream_widget.dart @@ -0,0 +1,94 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_example/utils.dart'; +import 'package:flutter/material.dart'; + +class StreamWidget extends StatefulWidget { + final AudioPlayer player; + + const StreamWidget({ + required this.player, + super.key, + }); + + @override + State createState() => _StreamWidgetState(); +} + +class _StreamWidgetState extends State { + Duration? streamDuration; + Duration? streamPosition; + PlayerState? streamState; + late List streams; + + AudioPlayer get player => widget.player; + + @override + void initState() { + super.initState(); + // Use initial values from player + streamState = player.state; + player.getDuration().then((it) => setState(() => streamDuration = it)); + player.getCurrentPosition().then( + (it) => setState(() => streamPosition = it), + ); + + streams = [ + player.onDurationChanged + .listen((it) => setState(() => streamDuration = it)), + player.onPlayerStateChanged + .listen((it) => setState(() => streamState = it)), + player.onPositionChanged + .listen((it) => setState(() => streamPosition = it)), + ]; + } + + @override + void dispose() { + super.dispose(); + streams.forEach((it) => it.cancel()); + } + + @override + void setState(VoidCallback fn) { + // Subscriptions only can be closed asynchronously, + // therefore events can occur after widget has been disposed. + if (mounted) { + super.setState(fn); + } + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const ListTile(title: Text('Streams')), + ListTile( + title: Text( + streamDuration?.toString() ?? '-', + key: const Key('onDurationText'), + ), + subtitle: const Text('Duration Stream'), + leading: const Icon(Icons.timelapse), + ), + ListTile( + title: Text( + streamPosition?.toString() ?? '-', + key: const Key('onPositionText'), + ), + subtitle: const Text('Position Stream'), + leading: const Icon(Icons.timer), + ), + ListTile( + title: Text( + streamState?.toString() ?? '-', + key: const Key('onStateText'), + ), + subtitle: const Text('State Stream'), + leading: Icon(streamState?.getIcon() ?? Icons.stop), + ), + ], + ); + } +} diff --git a/packages/audioplayers/example/lib/components/tab_content.dart b/packages/audioplayers/example/lib/components/tab_content.dart new file mode 100644 index 000000000..abbad293a --- /dev/null +++ b/packages/audioplayers/example/lib/components/tab_content.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class TabContent extends StatelessWidget { + final List children; + + const TabContent({ + required this.children, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Center( + child: Container( + alignment: Alignment.topCenter, + child: SingleChildScrollView( + controller: ScrollController(), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Column( + children: children, + ), + ), + ), + ), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/tab_wrapper.dart b/packages/audioplayers/example/lib/components/tab_wrapper.dart deleted file mode 100644 index 4028116db..000000000 --- a/packages/audioplayers/example/lib/components/tab_wrapper.dart +++ /dev/null @@ -1,29 +0,0 @@ -import 'package:flutter/material.dart'; - -class TabWrapper extends StatelessWidget { - final List children; - - const TabWrapper({Key? key, required this.children}) : super(key: key); - - @override - Widget build(BuildContext context) { - return Center( - child: Container( - alignment: Alignment.topCenter, - padding: const EdgeInsets.all(16.0), - child: SingleChildScrollView( - child: Column( - children: children - .map( - (w) => Container( - child: w, - padding: const EdgeInsets.all(6.0), - ), - ) - .toList(), - ), - ), - ), - ); - } -} diff --git a/packages/audioplayers/example/lib/components/tabs.dart b/packages/audioplayers/example/lib/components/tabs.dart new file mode 100644 index 000000000..0cd5c636a --- /dev/null +++ b/packages/audioplayers/example/lib/components/tabs.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +class Tabs extends StatelessWidget { + final List tabs; + + const Tabs({ + required this.tabs, + super.key, + }); + + @override + Widget build(BuildContext context) { + return DefaultTabController( + length: tabs.length, + child: Scaffold( + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TabBar( + labelColor: Colors.black, + tabs: tabs + .map( + (tData) => Tab( + key: tData.key != null ? Key(tData.key!) : null, + text: tData.label, + ), + ) + .toList(), + ), + Expanded( + child: TabBarView( + children: tabs.map((tab) => tab.content).toList(), + ), + ), + ], + ), + ), + ); + } +} + +class TabData { + final String? key; + final String label; + final Widget content; + + TabData({ + required this.label, + required this.content, + this.key, + }); +} diff --git a/packages/audioplayers/example/lib/components/tgl.dart b/packages/audioplayers/example/lib/components/tgl.dart new file mode 100644 index 000000000..d28a1df1c --- /dev/null +++ b/packages/audioplayers/example/lib/components/tgl.dart @@ -0,0 +1,61 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +class Tgl extends StatelessWidget { + final Map options; + final int selected; + final void Function(int) onChange; + + const Tgl({ + required this.options, + required this.selected, + required this.onChange, + super.key, + }); + + @override + Widget build(BuildContext context) { + return ToggleButtons( + isSelected: options.entries + .mapIndexed((index, element) => index == selected) + .toList(), + onPressed: onChange, + borderRadius: const BorderRadius.all(Radius.circular(8)), + selectedBorderColor: Theme.of(context).primaryColor, + children: options.entries + .map( + (entry) => Padding( + padding: const EdgeInsets.all(8), + child: Text( + entry.value, + key: Key(entry.key), + ), + ), + ) + .toList(), + ); + } +} + +class EnumTgl extends StatelessWidget { + final Map options; + final T selected; + final void Function(T) onChange; + + const EnumTgl({ + required this.options, + required this.selected, + required this.onChange, + super.key, + }); + + @override + Widget build(BuildContext context) { + final optionValues = options.values.toList(); + return Tgl( + options: options.map((key, value) => MapEntry(key, value.name)), + selected: optionValues.indexOf(selected), + onChange: (it) => onChange(optionValues[it]), + ); + } +} diff --git a/packages/audioplayers/example/lib/components/txt.dart b/packages/audioplayers/example/lib/components/txt.dart new file mode 100644 index 000000000..177251fab --- /dev/null +++ b/packages/audioplayers/example/lib/components/txt.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class TxtBox extends StatefulWidget { + final String value; + final void Function(String) onChange; + + const TxtBox({ + required this.value, + required this.onChange, + super.key, + }); + + @override + State createState() => _TxtBoxState(); +} + +class _TxtBoxState extends State { + late TextEditingController _controller; + + @override + void initState() { + super.initState(); + _controller = TextEditingController( + text: widget.value, + )..addListener(() => widget.onChange(_controller.text)); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return TextField(controller: _controller); + } +} diff --git a/packages/audioplayers/example/lib/generated_plugin_registrant.dart b/packages/audioplayers/example/lib/generated_plugin_registrant.dart deleted file mode 100644 index ac0c55182..000000000 --- a/packages/audioplayers/example/lib/generated_plugin_registrant.dart +++ /dev/null @@ -1,16 +0,0 @@ -// -// Generated file. Do not edit. -// - -// ignore_for_file: directives_ordering -// ignore_for_file: lines_longer_than_80_chars - -import 'package:audioplayers/web/audioplayers_web.dart'; - -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - -// ignore: public_member_api_docs -void registerPlugins(Registrar registrar) { - AudioplayersPlugin.registerWith(registrar); - registrar.registerMessageHandler(); -} diff --git a/packages/audioplayers/example/lib/main.dart b/packages/audioplayers/example/lib/main.dart index c4f31059e..d3ed95aa9 100644 --- a/packages/audioplayers/example/lib/main.dart +++ b/packages/audioplayers/example/lib/main.dart @@ -1,481 +1,203 @@ import 'dart:async'; -import 'dart:io'; import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter/foundation.dart'; +import 'package:audioplayers_example/components/tabs.dart'; +import 'package:audioplayers_example/components/tgl.dart'; +import 'package:audioplayers_example/tabs/audio_context.dart'; +import 'package:audioplayers_example/tabs/controls.dart'; +import 'package:audioplayers_example/tabs/logger.dart'; +import 'package:audioplayers_example/tabs/sources.dart'; +import 'package:audioplayers_example/tabs/streams.dart'; +import 'package:audioplayers_example/utils.dart'; import 'package:flutter/material.dart'; -import 'package:http/http.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:provider/provider.dart'; -import 'components/btn.dart'; -import 'components/tab_wrapper.dart'; -import 'player_widget.dart'; -import 'tabs/global.dart'; +const defaultPlayerCount = 4; typedef OnError = void Function(Exception exception); -const kUrl1 = 'https://luan.xyz/files/audio/ambient_c_motion.mp3'; -const kUrl2 = 'https://luan.xyz/files/audio/nasa_on_a_mission.mp3'; -const kUrl3 = 'http://bbcmedia.ic.llnwd.net/stream/bbcmedia_radio1xtra_mf_p'; - +/// The app is deployed at: https://bluefireteam.github.io/audioplayers/ void main() { - runApp(MaterialApp(home: ExampleApp())); + runApp(const MaterialApp(home: _ExampleApp())); } -class ExampleApp extends StatefulWidget { +class _ExampleApp extends StatefulWidget { + const _ExampleApp(); + @override _ExampleAppState createState() => _ExampleAppState(); } -class _ExampleAppState extends State { - AudioCache audioCache = AudioCache(); - AudioPlayer advancedPlayer = AudioPlayer(); - String? localFilePath; - String? localAudioCacheURI; +class _ExampleAppState extends State<_ExampleApp> { + List audioPlayers = List.generate( + defaultPlayerCount, + (_) => AudioPlayer()..setReleaseMode(ReleaseMode.stop), + ); + int selectedPlayerIdx = 0; + + AudioPlayer get selectedAudioPlayer => audioPlayers[selectedPlayerIdx]; + List streams = []; @override void initState() { super.initState(); - - if (kIsWeb) { - // Calls to Platform.isIOS fails on web - return; - } - if (Platform.isIOS) { - audioCache.fixedPlayer?.notificationService.startHeadlessService(); - } - } - - Future _loadFile() async { - final bytes = await readBytes(Uri.parse(kUrl1)); - final dir = await getApplicationDocumentsDirectory(); - final file = File('${dir.path}/audio.mp3'); - - await file.writeAsBytes(bytes); - if (file.existsSync()) { - setState(() => localFilePath = file.path); - } - } - - Widget remoteUrl() { - return const SingleChildScrollView( - child: TabWrapper( - children: [ - Text( - 'Sample 1 ($kUrl1)', - style: TextStyle(fontWeight: FontWeight.bold), - ), - PlayerWidget(url: kUrl1), - Text( - 'Sample 2 ($kUrl2)', - style: TextStyle(fontWeight: FontWeight.bold), - ), - PlayerWidget(url: kUrl2), - Text( - 'Sample 3 ($kUrl3)', - style: TextStyle(fontWeight: FontWeight.bold), - ), - PlayerWidget(url: kUrl3), - Text( - 'Sample 4 (Low Latency mode) ($kUrl1)', - style: TextStyle(fontWeight: FontWeight.bold), - ), - PlayerWidget(url: kUrl1, mode: PlayerMode.lowLatency), - ], - ), - ); - } - - Widget localFile() { - return TabWrapper( - children: [ - const Text(' -- manually load bytes (no web!) --'), - const Text('File: $kUrl1'), - Btn(txt: 'Download File to your Device', onPressed: _loadFile), - Text('Current local file path: $localFilePath'), - if (localFilePath != null) PlayerWidget(url: localFilePath!), - Container( - constraints: const BoxConstraints.expand(width: 1.0, height: 20.0), - ), - const Text(' -- via AudioCache --'), - const Text('File: $kUrl2'), - Btn(txt: 'Download File to your Device', onPressed: _loadFileAC), - Text('Current AC loaded: $localAudioCacheURI'), - if (localAudioCacheURI != null) PlayerWidget(url: localAudioCacheURI!), - ], - ); - } - - void _loadFileAC() async { - final uri = await audioCache.load(kUrl2); - setState(() => localAudioCacheURI = uri.toString()); - } - - Widget localAsset() { - return SingleChildScrollView( - child: TabWrapper( - children: [ - const Text("Play Local Asset 'audio.mp3':"), - Btn(txt: 'Play', onPressed: () => audioCache.play('audio.mp3')), - const Text("Play Local Asset (via byte source) 'audio.mp3':"), - Btn( - txt: 'Play', - onPressed: () async { - final file = await audioCache.loadAsFile('audio.mp3'); - final bytes = await file.readAsBytes(); - audioCache.playBytes(bytes); - }, - ), - const Text("Loop Local Asset 'audio.mp3':"), - Btn(txt: 'Loop', onPressed: () => audioCache.loop('audio.mp3')), - const Text("Loop Local Asset (via byte source) 'audio.mp3':"), - Btn( - txt: 'Loop', - onPressed: () async { - final file = await audioCache.loadAsFile('audio.mp3'); - final bytes = await file.readAsBytes(); - audioCache.playBytes(bytes, loop: true); - }, - ), - const Text("Play Local Asset 'audio2.mp3':"), - Btn(txt: 'Play', onPressed: () => audioCache.play('audio2.mp3')), - const Text("Play Local Asset In Low Latency 'audio.mp3':"), - Btn( - txt: 'Play', - onPressed: () { - audioCache.play('audio.mp3', mode: PlayerMode.lowLatency); - }, - ), - const Text( - "Play Local Asset Concurrently In Low Latency 'audio.mp3':", - ), - Btn( - txt: 'Play', - onPressed: () async { - await audioCache.play( - 'audio.mp3', - mode: PlayerMode.lowLatency, - ); - await audioCache.play( - 'audio2.mp3', - mode: PlayerMode.lowLatency, - ); - }, - ), - const Text("Play Local Asset In Low Latency 'audio2.mp3':"), - Btn( - txt: 'Play', - onPressed: () { - audioCache.play('audio2.mp3', mode: PlayerMode.lowLatency); - }, - ), - getLocalFileDuration(), - ], - ), - ); - } - - Future _getDuration() async { - final uri = await audioCache.load('audio2.mp3'); - await advancedPlayer.setUrl(uri.toString()); - return Future.delayed( - const Duration(seconds: 2), - () => advancedPlayer.getDuration(), - ); - } - - FutureBuilder getLocalFileDuration() { - return FutureBuilder( - future: _getDuration(), - builder: (BuildContext context, AsyncSnapshot snapshot) { - switch (snapshot.connectionState) { - case ConnectionState.none: - return const Text('No Connection...'); - case ConnectionState.active: - case ConnectionState.waiting: - return const Text('Awaiting result...'); - case ConnectionState.done: - if (snapshot.hasError) { - return Text('Error: ${snapshot.error}'); + audioPlayers.asMap().forEach((index, player) { + streams.add( + player.onPlayerStateChanged.listen( + (it) { + switch (it) { + case PlayerState.stopped: + toast( + 'Player stopped!', + textKey: Key('toast-player-stopped-$index'), + ); + case PlayerState.completed: + toast( + 'Player complete!', + textKey: Key('toast-player-complete-$index'), + ); + default: } - return Text( - 'audio2.mp3 duration is: ${Duration(milliseconds: snapshot.data!)}', - ); - } - }, - ); - } - - Widget notification() { - return TabWrapper( - children: [ - const Text("Play notification sound: 'messenger.mp3':"), - Btn( - txt: 'Play', - onPressed: () => - audioCache.play('messenger.mp3', isNotification: true), - ), - const Text('Notification Service'), - Btn( - txt: 'Notification', - onPressed: () async { - await advancedPlayer.notificationService.startHeadlessService(); - await advancedPlayer.notificationService.setNotification( - title: 'My Song', - albumTitle: 'My Album', - artist: 'My Artist', - imageUrl: 'Image URL or blank', - forwardSkipInterval: const Duration(seconds: 30), - backwardSkipInterval: const Duration(seconds: 30), - duration: const Duration(minutes: 3), - elapsedTime: const Duration(seconds: 15), - enableNextTrackButton: true, - enablePreviousTrackButton: true, - ); - - await advancedPlayer.play( - kUrl2, - isLocal: false, - ); - }, - ), - Btn( - txt: 'Clear Notification', - onPressed: () async { - await advancedPlayer.stop(); - await advancedPlayer.notificationService.clearNotification(); }, ), - ], - ); - } - - @override - Widget build(BuildContext context) { - return MultiProvider( - providers: [ - StreamProvider.value( - initialData: const Duration(), - value: advancedPlayer.onAudioPositionChanged, - ), - ], - child: DefaultTabController( - length: 6, - child: Scaffold( - appBar: AppBar( - bottom: const TabBar( - tabs: [ - Tab(text: 'Remote Url'), - Tab(text: 'Local File'), - Tab(text: 'Local Asset'), - Tab(text: 'Notification'), - Tab(text: 'Advanced'), - Tab(text: 'Global Config'), - ], - ), - title: const Text('audioplayers Example'), - ), - body: TabBarView( - children: [ - remoteUrl(), - localFile(), - localAsset(), - notification(), - Advanced(advancedPlayer: advancedPlayer), - const GlobalTab(), - ], + ); + streams.add( + player.onSeekComplete.listen( + (it) => toast( + 'Seek complete!', + textKey: Key('toast-seek-complete-$index'), ), ), - ), - ); + ); + }); } -} - -class Advanced extends StatefulWidget { - final AudioPlayer advancedPlayer; - - const Advanced({Key? key, required this.advancedPlayer}) : super(key: key); @override - _AdvancedState createState() => _AdvancedState(); -} - -class _AdvancedState extends State { - bool? seekDone; + void dispose() { + streams.forEach((it) => it.cancel()); + super.dispose(); + } - @override - void initState() { - widget.advancedPlayer.onSeekComplete - .listen((event) => setState(() => seekDone = true)); - super.initState(); + void _handleAction(PopupAction value) { + switch (value) { + case PopupAction.add: + setState(() { + audioPlayers.add(AudioPlayer()..setReleaseMode(ReleaseMode.stop)); + }); + case PopupAction.remove: + setState(() { + if (audioPlayers.isNotEmpty) { + selectedAudioPlayer.dispose(); + audioPlayers.removeAt(selectedPlayerIdx); + } + // Adjust index to be in valid range + if (audioPlayers.isEmpty) { + selectedPlayerIdx = 0; + } else if (selectedPlayerIdx >= audioPlayers.length) { + selectedPlayerIdx = audioPlayers.length - 1; + } + }); + } } @override Widget build(BuildContext context) { - final audioPosition = Provider.of(context); - return SingleChildScrollView( - child: TabWrapper( - children: [ - Column( - children: [ - const Text('Source Url'), - Row( - children: [ - Btn( - txt: 'Audio 1', - onPressed: () => widget.advancedPlayer.setUrl(kUrl1), - ), - Btn( - txt: 'Audio 2', - onPressed: () => widget.advancedPlayer.setUrl(kUrl2), - ), - Btn( - txt: 'Stream', - onPressed: () => widget.advancedPlayer.setUrl(kUrl3), + return Scaffold( + appBar: AppBar( + title: const Text('AudioPlayers example'), + actions: [ + PopupMenuButton( + onSelected: _handleAction, + itemBuilder: (BuildContext context) { + return PopupAction.values.map((PopupAction choice) { + return PopupMenuItem( + value: choice, + child: Text( + choice == PopupAction.add + ? 'Add player' + : 'Remove selected player', ), - ], - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - ), - ], - ), - Column( - children: [ - const Text('Release Mode'), - Row( - children: [ - Btn( - txt: 'STOP', - onPressed: () => - widget.advancedPlayer.setReleaseMode(ReleaseMode.stop), - ), - Btn( - txt: 'LOOP', - onPressed: () => - widget.advancedPlayer.setReleaseMode(ReleaseMode.loop), - ), - Btn( - txt: 'RELEASE', - onPressed: () => widget.advancedPlayer - .setReleaseMode(ReleaseMode.release), - ), - ], - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - ), - ], - ), - Column( - children: [ - const Text('Volume'), - Row( - children: [0.0, 0.3, 0.5, 1.0, 1.1, 2.0].map((e) { - return Btn( - txt: e.toString(), - onPressed: () => widget.advancedPlayer.setVolume(e), - ); - }).toList(), - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - ), - ], + ); + }).toList(); + }, ), - Column( - children: [ - const Text('Control'), - Row( - children: [ - Btn( - txt: 'resume', - onPressed: () => widget.advancedPlayer.resume(), - ), - Btn( - txt: 'pause', - onPressed: () => widget.advancedPlayer.pause(), - ), - Btn( - txt: 'stop', - onPressed: () => widget.advancedPlayer.stop(), - ), - Btn( - txt: 'release', - onPressed: () => widget.advancedPlayer.release(), - ), - ], - mainAxisAlignment: MainAxisAlignment.spaceEvenly, + ], + ), + body: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: Center( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Tgl( + key: const Key('playerTgl'), + options: [for (var i = 1; i <= audioPlayers.length; i++) i] + .asMap() + .map((key, val) => MapEntry('player-$key', 'P$val')), + selected: selectedPlayerIdx, + onChange: (v) => setState(() => selectedPlayerIdx = v), + ), ), - ], + ), ), - Column( - children: [ - const Text('Seek in milliseconds'), - Row( - children: [ - Btn( - txt: '100ms', - onPressed: () { - widget.advancedPlayer.seek( - Duration( - milliseconds: audioPosition.inMilliseconds + 100, - ), - ); - setState(() => seekDone = false); - }, - ), - Btn( - txt: '500ms', - onPressed: () { - widget.advancedPlayer.seek( - Duration( - milliseconds: audioPosition.inMilliseconds + 500, - ), - ); - setState(() => seekDone = false); - }, - ), - Btn( - txt: '1s', - onPressed: () { - widget.advancedPlayer.seek( - Duration(seconds: audioPosition.inSeconds + 1), - ); - setState(() => seekDone = false); - }, + Expanded( + child: audioPlayers.isEmpty + ? const Text('No AudioPlayer available!') + : IndexedStack( + index: selectedPlayerIdx, + children: audioPlayers + .map( + (player) => Tabs( + key: GlobalObjectKey(player), + tabs: [ + TabData( + key: 'sourcesTab', + label: 'Src', + content: SourcesTab( + player: player, + ), + ), + TabData( + key: 'controlsTab', + label: 'Ctrl', + content: ControlsTab( + player: player, + ), + ), + TabData( + key: 'streamsTab', + label: 'Stream', + content: StreamsTab( + player: player, + ), + ), + TabData( + key: 'audioContextTab', + label: 'Ctx', + content: AudioContextTab( + player: player, + ), + ), + TabData( + key: 'loggerTab', + label: 'Log', + content: LoggerTab( + player: player, + ), + ), + ], + ), + ) + .toList(), ), - Btn( - txt: '1.5s', - onPressed: () { - widget.advancedPlayer.seek( - Duration( - milliseconds: audioPosition.inMilliseconds + 1500, - ), - ); - setState(() => seekDone = false); - }, - ), - ], - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - ), - ], - ), - Column( - children: [ - const Text('Rate'), - Row( - children: [0.5, 1.0, 1.5, 2.0, 5.0].map((e) { - return Btn( - txt: e.toString(), - onPressed: () { - widget.advancedPlayer.setPlaybackRate(e); - }, - ); - }).toList(), - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - ), - ], ), - Text('Audio Position: $audioPosition'), - if (seekDone != null) Text(seekDone! ? 'Seek Done' : 'Seeking...'), ], ), ); } } + +enum PopupAction { + add, + remove, +} diff --git a/packages/audioplayers/example/lib/player_widget.dart b/packages/audioplayers/example/lib/player_widget.dart deleted file mode 100644 index 81703de3a..000000000 --- a/packages/audioplayers/example/lib/player_widget.dart +++ /dev/null @@ -1,264 +0,0 @@ -import 'dart:async'; - -import 'package:audioplayers/audioplayers.dart'; -import 'package:audioplayers/notifications.dart'; -import 'package:flutter/material.dart'; - -class PlayerWidget extends StatefulWidget { - final String url; - final PlayerMode mode; - - const PlayerWidget({ - Key? key, - required this.url, - this.mode = PlayerMode.mediaPlayer, - }) : super(key: key); - - @override - State createState() { - return _PlayerWidgetState(url, mode); - } -} - -class _PlayerWidgetState extends State { - String url; - PlayerMode mode; - - late AudioPlayer _audioPlayer; - PlayerState? _audioPlayerState; - Duration? _duration; - Duration? _position; - - PlayerState _playerState = PlayerState.stopped; - PlayingRoute _playingRouteState = PlayingRoute.speakers; - StreamSubscription? _durationSubscription; - StreamSubscription? _positionSubscription; - StreamSubscription? _playerCompleteSubscription; - StreamSubscription? _playerErrorSubscription; - StreamSubscription? _playerStateSubscription; - StreamSubscription? _playerControlCommandSubscription; - - bool get _isPlaying => _playerState == PlayerState.playing; - bool get _isPaused => _playerState == PlayerState.paused; - String get _durationText => _duration?.toString().split('.').first ?? ''; - String get _positionText => _position?.toString().split('.').first ?? ''; - - bool get _isPlayingThroughEarpiece => - _playingRouteState == PlayingRoute.earpiece; - - _PlayerWidgetState(this.url, this.mode); - - @override - void initState() { - super.initState(); - _initAudioPlayer(); - } - - @override - void dispose() { - _audioPlayer.dispose(); - _durationSubscription?.cancel(); - _positionSubscription?.cancel(); - _playerCompleteSubscription?.cancel(); - _playerErrorSubscription?.cancel(); - _playerStateSubscription?.cancel(); - _playerControlCommandSubscription?.cancel(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - key: const Key('play_button'), - onPressed: _isPlaying ? null : _play, - iconSize: 64.0, - icon: const Icon(Icons.play_arrow), - color: Colors.cyan, - ), - IconButton( - key: const Key('pause_button'), - onPressed: _isPlaying ? _pause : null, - iconSize: 64.0, - icon: const Icon(Icons.pause), - color: Colors.cyan, - ), - IconButton( - key: const Key('stop_button'), - onPressed: _isPlaying || _isPaused ? _stop : null, - iconSize: 64.0, - icon: const Icon(Icons.stop), - color: Colors.cyan, - ), - IconButton( - onPressed: _earpieceOrSpeakersToggle, - iconSize: 64.0, - icon: _isPlayingThroughEarpiece - ? const Icon(Icons.volume_up) - : const Icon(Icons.hearing), - color: Colors.cyan, - ), - ], - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: Stack( - children: [ - Slider( - onChanged: (v) { - final duration = _duration; - if (duration == null) { - return; - } - final Position = v * duration.inMilliseconds; - _audioPlayer - .seek(Duration(milliseconds: Position.round())); - }, - value: (_position != null && - _duration != null && - _position!.inMilliseconds > 0 && - _position!.inMilliseconds < - _duration!.inMilliseconds) - ? _position!.inMilliseconds / _duration!.inMilliseconds - : 0.0, - ), - ], - ), - ), - Text( - _position != null - ? '$_positionText / $_durationText' - : _duration != null - ? _durationText - : '', - style: const TextStyle(fontSize: 24.0), - ), - ], - ), - Text('State: $_audioPlayerState'), - ], - ); - } - - void _initAudioPlayer() { - _audioPlayer = AudioPlayer(mode: mode); - - _durationSubscription = _audioPlayer.onDurationChanged.listen((duration) { - setState(() => _duration = duration); - - if (Theme.of(context).platform == TargetPlatform.iOS) { - // optional: listen for notification updates in the background - _audioPlayer.notificationService.startHeadlessService(); - - // set at least title to see the notification bar on ios. - _audioPlayer.notificationService.setNotification( - title: 'App Name', - artist: 'Artist or blank', - albumTitle: 'Name or blank', - imageUrl: 'Image URL or blank', - forwardSkipInterval: const Duration(seconds: 30), // default is 30s - backwardSkipInterval: const Duration(seconds: 30), // default is 30s - duration: duration, - enableNextTrackButton: true, - enablePreviousTrackButton: true, - ); - } - }); - - _positionSubscription = _audioPlayer.onAudioPositionChanged.listen( - (p) => setState(() => _position = p), - ); - - _playerCompleteSubscription = - _audioPlayer.onPlayerCompletion.listen((event) { - _onComplete(); - setState(() { - _position = _duration; - }); - }); - - _playerErrorSubscription = _audioPlayer.onPlayerError.listen((msg) { - print('audioPlayer error : $msg'); - setState(() { - _playerState = PlayerState.stopped; - _duration = const Duration(); - _position = const Duration(); - }); - }); - - _playerControlCommandSubscription = - _audioPlayer.notificationService.onPlayerCommand.listen((command) { - print('command: $command'); - }); - - _audioPlayer.onPlayerStateChanged.listen((state) { - if (mounted) { - setState(() { - _audioPlayerState = state; - }); - } - }); - - _audioPlayer.onNotificationPlayerStateChanged.listen((state) { - if (mounted) { - setState(() => _audioPlayerState = state); - } - }); - - _playingRouteState = PlayingRoute.speakers; - } - - Future _play() async { - final playPosition = (_position != null && - _duration != null && - _position!.inMilliseconds > 0 && - _position!.inMilliseconds < _duration!.inMilliseconds) - ? _position - : null; - final result = await _audioPlayer.play(url, position: playPosition); - if (result == 1) { - setState(() => _playerState = PlayerState.playing); - } - - return result; - } - - Future _pause() async { - final result = await _audioPlayer.pause(); - if (result == 1) { - setState(() => _playerState = PlayerState.paused); - } - return result; - } - - Future _earpieceOrSpeakersToggle() async { - final result = await _audioPlayer.earpieceOrSpeakersToggle(); - if (result == 1) { - setState(() => _playingRouteState = _playingRouteState.toggle()); - } - return result; - } - - Future _stop() async { - final result = await _audioPlayer.stop(); - if (result == 1) { - setState(() { - _playerState = PlayerState.stopped; - _position = const Duration(); - }); - } - return result; - } - - void _onComplete() { - setState(() => _playerState = PlayerState.stopped); - } -} diff --git a/packages/audioplayers/example/lib/tabs/audio_context.dart b/packages/audioplayers/example/lib/tabs/audio_context.dart new file mode 100644 index 000000000..e2196d9c7 --- /dev/null +++ b/packages/audioplayers/example/lib/tabs/audio_context.dart @@ -0,0 +1,280 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_example/components/cbx.dart'; +import 'package:audioplayers_example/components/drop_down.dart'; +import 'package:audioplayers_example/components/tab_content.dart'; +import 'package:audioplayers_example/components/tabs.dart'; +import 'package:audioplayers_example/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class AudioContextTab extends StatefulWidget { + final AudioPlayer player; + + const AudioContextTab({ + required this.player, + super.key, + }); + + @override + AudioContextTabState createState() => AudioContextTabState(); +} + +class AudioContextTabState extends State + with AutomaticKeepAliveClientMixin { + static GlobalAudioScope get _global => AudioPlayer.global; + + AudioPlayer get player => widget.player; + + /// Set config for all platforms + AudioContextConfig audioContextConfig = AudioContextConfig(); + + /// Set config for each platform individually + AudioContext audioContext = AudioContext(); + + bool isLocal = true; + bool isGlobal = false; + + @override + Widget build(BuildContext context) { + super.build(context); + return Column( + children: [ + const ListTile(title: Text('Audio Context')), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + ToggleButtons( + isSelected: [isGlobal, isLocal], + onPressed: (index) { + if (index == 0) { + setState(() { + isGlobal = !isGlobal; + }); + } else if (index == 1) { + setState(() { + isLocal = !isLocal; + }); + } + }, + borderRadius: const BorderRadius.all(Radius.circular(8)), + selectedBorderColor: Theme.of(context).primaryColor, + children: const [ + Padding( + padding: EdgeInsets.all(8), + child: Text('Global'), + ), + Padding( + padding: EdgeInsets.all(8), + child: Text('Local'), + ), + ], + ), + ElevatedButton.icon( + icon: const Icon(Icons.undo), + label: const Text('Reset'), + onPressed: () => updateConfig(AudioContextConfig()), + ), + ], + ), + const SizedBox(height: 8), + Expanded( + child: Tabs( + tabs: [ + TabData( + key: 'contextTab-genericFlags', + label: 'Generic Flags', + content: _genericTab(), + ), + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + TabData( + key: 'contextTab-android', + label: 'Android', + content: _androidTab(), + ), + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) + TabData( + key: 'contextTab-ios', + label: 'iOS', + content: _iosTab(), + ), + ], + ), + ), + ], + ); + } + + void updateConfig(AudioContextConfig newConfig) { + try { + final context = newConfig.build(); + setState(() { + audioContextConfig = newConfig; + audioContext = context; + _applyAudioContext(audioContext); + }); + } on AssertionError catch (e) { + toast(e.message.toString()); + } + } + + void updateAudioContextAndroid(AudioContextAndroid contextAndroid) { + setState(() { + audioContext = audioContext.copy(android: contextAndroid); + _applyAudioContext(audioContext); + }); + } + + void updateAudioContextIOS(AudioContextIOS Function() buildContextIOS) { + try { + final context = buildContextIOS(); + setState(() { + audioContext = audioContext.copy(iOS: context); + _applyAudioContext(audioContext); + }); + } on AssertionError catch (e) { + toast(e.message.toString()); + } + } + + void _applyAudioContext(AudioContext context) { + if (isGlobal) { + _global.setAudioContext(context); + } + if (isLocal) { + player.setAudioContext(context); + } + } + + Widget _genericTab() { + return TabContent( + children: [ + LabeledDropDown( + label: 'Audio Route', + key: const Key('audioRoute'), + options: {for (final e in AudioContextConfigRoute.values) e: e.name}, + selected: audioContextConfig.route, + onChange: (v) => updateConfig( + audioContextConfig.copy(route: v), + ), + ), + LabeledDropDown( + label: 'Audio Focus', + key: const Key('audioFocus'), + options: {for (final e in AudioContextConfigFocus.values) e: e.name}, + selected: audioContextConfig.focus, + onChange: (v) => updateConfig( + audioContextConfig.copy(focus: v), + ), + ), + Cbx( + 'Respect Silence', + value: audioContextConfig.respectSilence, + ({value}) => + updateConfig(audioContextConfig.copy(respectSilence: value)), + ), + Cbx( + 'Stay Awake', + value: audioContextConfig.stayAwake, + ({value}) => updateConfig(audioContextConfig.copy(stayAwake: value)), + ), + ], + ); + } + + Widget _androidTab() { + return TabContent( + children: [ + Cbx( + 'isSpeakerphoneOn', + value: audioContext.android.isSpeakerphoneOn, + ({value}) => updateAudioContextAndroid( + audioContext.android.copy(isSpeakerphoneOn: value), + ), + ), + Cbx( + 'stayAwake', + value: audioContext.android.stayAwake, + ({value}) => updateAudioContextAndroid( + audioContext.android.copy(stayAwake: value), + ), + ), + LabeledDropDown( + label: 'contentType', + key: const Key('contentType'), + options: {for (final e in AndroidContentType.values) e: e.name}, + selected: audioContext.android.contentType, + onChange: (v) => updateAudioContextAndroid( + audioContext.android.copy(contentType: v), + ), + ), + LabeledDropDown( + label: 'usageType', + key: const Key('usageType'), + options: {for (final e in AndroidUsageType.values) e: e.name}, + selected: audioContext.android.usageType, + onChange: (v) => updateAudioContextAndroid( + audioContext.android.copy(usageType: v), + ), + ), + LabeledDropDown( + key: const Key('audioFocus'), + label: 'audioFocus', + options: {for (final e in AndroidAudioFocus.values) e: e.name}, + selected: audioContext.android.audioFocus, + onChange: (v) => updateAudioContextAndroid( + audioContext.android.copy(audioFocus: v), + ), + ), + LabeledDropDown( + key: const Key('audioMode'), + label: 'audioMode', + options: {for (final e in AndroidAudioMode.values) e: e.name}, + selected: audioContext.android.audioMode, + onChange: (v) => updateAudioContextAndroid( + audioContext.android.copy(audioMode: v), + ), + ), + ], + ); + } + + Widget _iosTab() { + final iosOptions = AVAudioSessionOptions.values.map( + (option) { + final options = {...audioContext.iOS.options}; + return Cbx( + option.name, + value: options.contains(option), + ({value}) { + updateAudioContextIOS(() { + final iosContext = audioContext.iOS.copy(options: options); + if (value ?? false) { + options.add(option); + } else { + options.remove(option); + } + return iosContext; + }); + }, + ); + }, + ).toList(); + return TabContent( + children: [ + LabeledDropDown( + key: const Key('category'), + label: 'category', + options: {for (final e in AVAudioSessionCategory.values) e: e.name}, + selected: audioContext.iOS.category, + onChange: (v) => updateAudioContextIOS( + () => audioContext.iOS.copy(category: v), + ), + ), + ...iosOptions, + ], + ); + } + + @override + bool get wantKeepAlive => true; +} diff --git a/packages/audioplayers/example/lib/tabs/controls.dart b/packages/audioplayers/example/lib/tabs/controls.dart new file mode 100644 index 000000000..9bbeb621a --- /dev/null +++ b/packages/audioplayers/example/lib/tabs/controls.dart @@ -0,0 +1,243 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_example/components/btn.dart'; +import 'package:audioplayers_example/components/list_tile.dart'; +import 'package:audioplayers_example/components/tab_content.dart'; +import 'package:audioplayers_example/components/tgl.dart'; +import 'package:audioplayers_example/components/txt.dart'; +import 'package:audioplayers_example/utils.dart'; +import 'package:flutter/material.dart'; + +class ControlsTab extends StatefulWidget { + final AudioPlayer player; + + const ControlsTab({ + required this.player, + super.key, + }); + + @override + State createState() => _ControlsTabState(); +} + +class _ControlsTabState extends State + with AutomaticKeepAliveClientMixin { + String modalInputSeek = ''; + + Future _update(Future Function() fn) async { + await fn(); + // update everyone who listens to "player" + setState(() {}); + } + + Future _seekPercent(double percent) async { + final duration = await widget.player.getDuration(); + if (duration == null) { + toast( + 'Failed to get duration for proportional seek.', + textKey: const Key('toast-proportional-seek-duration-null'), + ); + return; + } + final position = duration * percent; + _seekDuration(position); + } + + Future _seekDuration(Duration position) async { + await _update( + () => widget.player.seek(position), + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return TabContent( + children: [ + WrappedListTile( + children: [ + Btn( + key: const Key('control-pause'), + txt: 'Pause', + onPressed: widget.player.pause, + ), + Btn( + key: const Key('control-stop'), + txt: 'Stop', + onPressed: widget.player.stop, + ), + Btn( + key: const Key('control-resume'), + txt: 'Resume', + onPressed: widget.player.resume, + ), + Btn( + key: const Key('control-release'), + txt: 'Release', + onPressed: widget.player.release, + ), + ], + ), + WrappedListTile( + leading: const Text('Volume'), + children: [0.0, 0.5, 1.0, 2.0].map((it) { + final formattedVal = it.toStringAsFixed(1); + return Btn( + key: Key('control-volume-$formattedVal'), + txt: formattedVal, + onPressed: () => widget.player.setVolume(it), + ); + }).toList(), + ), + WrappedListTile( + leading: const Text('Balance'), + children: [-1.0, -0.5, 0.0, 1.0].map((it) { + final formattedVal = it.toStringAsFixed(1); + return Btn( + key: Key('control-balance-$formattedVal'), + txt: formattedVal, + onPressed: () => widget.player.setBalance(it), + ); + }).toList(), + ), + WrappedListTile( + leading: const Text('Rate'), + children: [0.0, 0.5, 1.0, 2.0].map((it) { + final formattedVal = it.toStringAsFixed(1); + return Btn( + key: Key('control-rate-$formattedVal'), + txt: formattedVal, + onPressed: () => widget.player.setPlaybackRate(it), + ); + }).toList(), + ), + WrappedListTile( + leading: const Text('Player Mode'), + children: [ + EnumTgl( + key: const Key('control-player-mode'), + options: { + for (final e in PlayerMode.values) + 'control-player-mode-${e.name}': e, + }, + selected: widget.player.mode, + onChange: (playerMode) async { + await _update(() => widget.player.setPlayerMode(playerMode)); + }, + ), + ], + ), + WrappedListTile( + leading: const Text('Release Mode'), + children: [ + EnumTgl( + key: const Key('control-release-mode'), + options: { + for (final e in ReleaseMode.values) + 'control-release-mode-${e.name}': e, + }, + selected: widget.player.releaseMode, + onChange: (releaseMode) async { + await _update( + () => widget.player.setReleaseMode(releaseMode), + ); + }, + ), + ], + ), + WrappedListTile( + leading: const Text('Seek'), + children: [ + ...[0.0, 0.5, 1.0].map((it) { + final formattedVal = it.toStringAsFixed(1); + return Btn( + key: Key('control-seek-$formattedVal'), + txt: formattedVal, + onPressed: () => _seekPercent(it), + ); + }), + Btn( + txt: 'Custom', + onPressed: () async { + dialog( + _SeekDialog( + value: modalInputSeek, + setValue: (it) => setState(() => modalInputSeek = it), + seekDuration: () => _seekDuration( + Duration( + milliseconds: int.parse(modalInputSeek), + ), + ), + seekPercent: () => _seekPercent( + double.parse(modalInputSeek), + ), + ), + ); + }, + ), + ], + ), + ], + ); + } + + @override + bool get wantKeepAlive => true; +} + +class _SeekDialog extends StatelessWidget { + final VoidCallback seekDuration; + final VoidCallback seekPercent; + final void Function(String val) setValue; + final String value; + + const _SeekDialog({ + required this.seekDuration, + required this.seekPercent, + required this.value, + required this.setValue, + }); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Pick a duration and unit to seek'), + TxtBox( + value: value, + onChange: setValue, + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Btn( + txt: 'millis', + onPressed: () { + Navigator.of(context).pop(); + seekDuration(); + }, + ), + Btn( + txt: 'seconds', + onPressed: () { + Navigator.of(context).pop(); + seekDuration(); + }, + ), + Btn( + txt: '%', + onPressed: () { + Navigator.of(context).pop(); + seekPercent(); + }, + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + ], + ), + ], + ); + } +} diff --git a/packages/audioplayers/example/lib/tabs/global.dart b/packages/audioplayers/example/lib/tabs/global.dart deleted file mode 100644 index 6722ea9f2..000000000 --- a/packages/audioplayers/example/lib/tabs/global.dart +++ /dev/null @@ -1,37 +0,0 @@ -import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter/material.dart'; - -import '../components/btn.dart'; -import '../components/tab_wrapper.dart'; - -class GlobalTab extends StatefulWidget { - const GlobalTab({Key? key}) : super(key: key); - - @override - _GlobalTabState createState() => _GlobalTabState(); -} - -class _GlobalTabState extends State { - LogLevel currentLogLevel = Logger.logLevel; - @override - Widget build(BuildContext context) { - return TabWrapper( - children: [ - Text('Log Level: $currentLogLevel'), - Row( - children: LogLevel.values - .map( - (e) => Btn( - txt: e.toString(), - onPressed: () async { - await Logger.changeLogLevel(e); - setState(() => currentLogLevel = Logger.logLevel); - }, - ), - ) - .toList(), - ), - ], - ); - } -} diff --git a/packages/audioplayers/example/lib/tabs/logger.dart b/packages/audioplayers/example/lib/tabs/logger.dart new file mode 100644 index 000000000..fa8f77d40 --- /dev/null +++ b/packages/audioplayers/example/lib/tabs/logger.dart @@ -0,0 +1,185 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_example/components/btn.dart'; +import 'package:flutter/material.dart'; + +class LoggerTab extends StatefulWidget { + final AudioPlayer player; + + const LoggerTab({ + required this.player, + super.key, + }); + + @override + LoggerTabState createState() => LoggerTabState(); +} + +class LoggerTabState extends State + with AutomaticKeepAliveClientMixin { + AudioLogLevel get currentLogLevel => AudioLogger.logLevel; + + set currentLogLevel(AudioLogLevel level) { + AudioLogger.logLevel = level; + } + + List logs = []; + List globalLogs = []; + + @override + void initState() { + super.initState(); + AudioPlayer.global.onLog.listen( + (message) { + if (AudioLogLevel.info.level <= currentLogLevel.level) { + setState(() { + globalLogs.add(Log(message, level: AudioLogLevel.info)); + }); + } + }, + onError: (Object o, [StackTrace? stackTrace]) { + if (AudioLogLevel.error.level <= currentLogLevel.level) { + setState(() { + globalLogs.add( + Log( + AudioLogger.errorToString(o, stackTrace), + level: AudioLogLevel.error, + ), + ); + }); + } + }, + ); + widget.player.onLog.listen( + (message) { + if (AudioLogLevel.info.level <= currentLogLevel.level) { + final msg = '$message\nSource: ${widget.player.source}'; + setState(() { + logs.add(Log(msg, level: AudioLogLevel.info)); + }); + } + }, + onError: (Object o, [StackTrace? stackTrace]) { + if (AudioLogLevel.error.level <= currentLogLevel.level) { + setState(() { + logs.add( + Log( + AudioLogger.errorToString( + AudioPlayerException(widget.player, cause: o), + stackTrace, + ), + level: AudioLogLevel.error, + ), + ); + }); + } + }, + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Padding( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + ListTile( + title: Text(currentLogLevel.toString()), + subtitle: const Text('Log Level'), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: AudioLogLevel.values + .map( + (level) => Btn( + txt: level.toString().replaceAll('AudioLogLevel.', ''), + onPressed: () { + setState(() => currentLogLevel = level); + }, + ), + ) + .toList(), + ), + const Divider(color: Colors.black), + Expanded( + child: LogView( + title: 'Player Logs:', + logs: logs, + onDelete: () => setState(() { + logs.clear(); + }), + ), + ), + const Divider(color: Colors.black), + Expanded( + child: LogView( + title: 'Global Logs:', + logs: globalLogs, + onDelete: () => setState(() { + globalLogs.clear(); + }), + ), + ), + ], + ), + ); + } + + @override + bool get wantKeepAlive => true; +} + +class LogView extends StatelessWidget { + final String title; + final List logs; + final VoidCallback onDelete; + + const LogView({ + required this.logs, + required this.title, + required this.onDelete, + super.key, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(title), + IconButton(onPressed: onDelete, icon: const Icon(Icons.delete)), + ], + ), + Expanded( + child: ListView( + children: logs + .map( + (log) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + '${log.level}: ${log.message}', + style: log.level == AudioLogLevel.error + ? const TextStyle(color: Colors.red) + : null, + ), + Divider(color: Colors.grey.shade400), + ], + ), + ) + .toList(), + ), + ), + ], + ); + } +} + +class Log { + Log(this.message, {required this.level}); + + final AudioLogLevel level; + final String message; +} diff --git a/packages/audioplayers/example/lib/tabs/sources.dart b/packages/audioplayers/example/lib/tabs/sources.dart new file mode 100644 index 000000000..839d8081f --- /dev/null +++ b/packages/audioplayers/example/lib/tabs/sources.dart @@ -0,0 +1,471 @@ +import 'dart:io'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_example/components/btn.dart'; +import 'package:audioplayers_example/components/drop_down.dart'; +import 'package:audioplayers_example/components/tab_content.dart'; +import 'package:audioplayers_example/utils.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:http/http.dart' as http; + +const useLocalServer = bool.fromEnvironment('USE_LOCAL_SERVER'); + +final localhost = kIsWeb || !Platform.isAndroid ? 'localhost' : '10.0.2.2'; +final host = useLocalServer ? 'http://$localhost:8080' : 'https://luan.xyz'; + +final wavUrl1 = '$host/files/audio/coins.wav'; +final wavUrl2 = '$host/files/audio/laser.wav'; +final wavUrl3 = '$host/files/audio/coins_non_ascii_и.wav'; +final mp3Url1 = '$host/files/audio/ambient_c_motion.mp3'; +final mp3Url2 = '$host/files/audio/nasa_on_a_mission.mp3'; +final m3u8StreamUrl = useLocalServer + ? '$host/files/live_streams/nasa_power_of_the_rovers.m3u8' + : 'https://raw.githubusercontent.com/bluefireteam/audioplayers/refs/heads/main/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers.m3u8'; +final mpgaStreamUrl = useLocalServer + ? '$host/stream/mpeg' + : 'https://timesradio.wireless.radio/stream'; + +const wavAsset1 = 'coins.wav'; +const wavAsset2 = 'laser.wav'; +const mp3Asset = 'nasa_on_a_mission.mp3'; +const invalidAsset = 'invalid.txt'; +const specialCharAsset = 'coins_non_ascii_и.wav'; +const noExtensionAsset = 'coins_no_extension'; +const wavDataUri = + 'data:audio/x-wav;base64,UklGRoibAABXQVZFZm10IBAAAAABAAEARKwAAIhYAQACABAAZGF0YWSbAABPMMkvRC++Ljkusy0uLagsIyydKxgrkyoNKogpAyl9KPgncyfuJmgm4yVeJdkkVCTmCJPcGN2e3SPeqd4u37TfOeC/4EThyeFP4tTiWePf42Tk6eRv5fTleeb+5v/m/+YA538M/hj9GPwY/Bj7GPoY+Rj4GPcY9xj2GPUY9BjzGPIY8hjxGPAY7xjuGO0Y7RjsGOsY/eb+5v7m/+YA5wHnAucD5wPnBOcF5wbnB+cI5wjnCecK5wvnDOcN5w3nDucP5xDnxPnuGO4Y7RjsGOsY6hjpGOkY6BjnGOYY5RjkGOQY4xjiGOEY4BjfGN8Y3hjdGNwY2xi6+Q3nDucP5xDnEOcR5xLnE+cU5xXnFecW5xfnGOcZ5xrnGucb5xznHece5x/nH+cg598Y3hjdGNwY2xjbGNoY2RjYGNcY1hjWGNUY1BjTGNIY0RjRGNAYzxjOGM0YzBjMGF8MHece5x7nH+cg5yHnIucj5yPnJOcl5ybnJ+co5yjnKecq5yvnLOct5y3nLucv5zDnNAbOGM0YzRjMGMsYyhjJGMgYyBjHGMYYxRjEGMMYwxjCGMEYwBi/GL4Yvhi9GLwYuxhe7S3nLucv5zDnMOcx5zLnM+c05zXnNec25zfnOOc55zrnOuc75zznPec+5z/nP+eg878Yvhi9GLwYuxi6GLoYuRi4GLcYthi1GLUYtBizGLIYsRiwGLAYrxiuGK0YrBisGPP/Pec+5z7nP+dA50HnQudD50PnROdF50bnR+dI50jnSedK50vnTOdM503nTudP51DngxKuGK0YrBisGKsYqhipGKgYpxinGKYYpRikGKMYohiiGKEYoBifGJ4YnhidGJwYcRJM503nTudP51DnUOdR51LnU+dU51XnVedW51fnWOdZ51rnWudb51znXede51/nX+cAAJ4YnhidGJwYmxiaGJkYmRiYGJcYlhiVGJQYlBiTGJIYkRiQGJAYjxiOGI0YjBiLGKjzXede517nX+dg52HnYudj52PnZOdl52bnZ+dn52jnaedq52vnbOds523nbudv55TtjxiOGI0YjBiLGIsYihiJGIgYhxiGGIYYhRiEGIMYghiCGIEYgBh/GH4YfRh9GHwYFQZs523nbudv53DncOdx53Lnc+d053Xnded253fneOd553rneud753znfed+53/nf+c/DH4YfRh9GHwYexh6GHkYeBh4GHcYdhh1GHQYdBhzGHIYcRhwGG8YbxhuGG0YbBhrGHznfed+537nf+eA54HngueD54PnhOeF54bnh+eH54jnieeK54vnjOeM543njueP5+P5bxhuGG0YbBhrGGoYahhpGGgYZxhmGGYYZRhkGGMYYhhhGGEYYBhfGF4YXRhdGFwY2fmM543njueP55DnkOeR55Lnk+eU55XnleeW55fnmOeZ55rnmueb55znneee557nn+dfGF4YXRhcGFwYWxhaGFkYWBhYGFcYVhhVGFQYUxhTGFIYURhQGE8YTxhOGE0YTBgfDJznneee557nn+eg56Hnouej56PnpOel56bnp+en56jnqeeq56vnrOes563nruev5xMGThhOGE0YTBhLGEoYShhJGEgYRxhGGEUYRRhEGEMYQhhBGEEYQBg/GD4YPRg8GDwYvu2s563nruev57Dnseex57Lns+e057Xntee257fnuOe557rnuue757znvee+577n3/M/GD4YPRg8GDwYOxg6GDkYOBg3GDcYNhg1GDQYMxgzGDIYMRgwGC8YLhguGC0YLBjz/7znvee+577nv+fA58HnwufD58PnxOfF58bnx+fH58jnyefK58vnzOfM583nzufP5yMSLhguGC0YLBgrGCoYKRgpGCgYJxgmGCUYJRgkGCMYIhghGCAYIBgfGB4YHRgcGBESzOfM583nzufP59Dn0efR59Ln0+fU59Xn1efW59fn2OfZ59rn2ufb59zn3efe597n//8fGB4YHRgcGBsYGxgaGBkYGBgXGBYYFhgVGBQYExgSGBIYERgQGA8YDhgOGA0YDBjn89zn3efe597n3+fg5+Hn4ufj5+Pn5Ofl5+bn5+fn5+jn6efq5+vn7Ofs5+3n7ufz7Q8YDhgNGA0YDBgLGAoYCRgIGAgYBxgGGAUYBBgEGAMYAhgBGAAYABj/F/4X/Rf8F/UF7Ofs5+3n7ufv5/Dn8efx5/Ln8+f05/Xn9ef25/fn+Of55/rn+uf75/zn/ef+5/7n/wv/F/4X/Rf8F/sX+hf6F/kX+Bf3F/YX9hf1F/QX8xfyF/EX8RfwF+8X7hftF+0X7Bf75/zn/ef+5//n/+cA6AHoAugD6APoBOgF6AboB+gH6AjoCegK6AvoDOgM6A3oDugD+u8X7hftF+wX7BfrF+oX6RfoF+gX5xfmF+UX5BfjF+MX4hfhF+AX3xffF94X3RfcF/n5DOgM6A3oDugP6BDoEegR6BLoE+gU6BXoFegW6BfoGOgZ6BnoGugb6BzoHege6B7o3xfeF94X3RfcF9sX2hfaF9kX2BfXF9YX1RfVF9QX0xfSF9EX0RfQF88XzhfNF80X4Asb6BzoHege6B/oH+gg6CHoIugj6CPoJOgl6CboJ+gn6CjoKegq6CvoLOgs6C3oLujzBc8XzhfNF8wXyxfLF8oXyRfIF8cXxxfGF8UXxBfDF8MXwhfBF8AXvxe/F74XvRe8Fx3uLOgt6C3oLugv6DDoMegx6DLoM+g06DXoNeg26DfoOOg56DnoOug76DzoPeg+6B/0vxe+F70XvRe8F7sXuhe5F7kXuBe3F7YXtRe1F7QXsxeyF7EXsRewF68XrhetF6wX8/876DzoPeg+6D/oP+hA6EHoQuhD6EPoROhF6EboR+hH6EjoSehK6EvoS+hM6E3oTujDEa8XrhetF6wXqxerF6oXqReoF6cXpxemF6UXpBejF6IXohehF6AXnxeeF54XnReyEUvoTOhN6E3oTuhP6FDoUehR6FLoU+hU6FXoVehW6FfoWOhZ6FnoWuhb6FzoXehe6P//nxeeF50XnRecF5sXmheZF5gXmBeXF5YXlReUF5QXkxeSF5EXkBeQF48XjheNF4wXJ/Rb6FzoXehe6F/oX+hg6GHoYuhj6GPoZOhl6GboZ+hn6Gjoaehq6Gvoa+hs6G3oUu6PF44XjheNF4wXixeKF4oXiReIF4cXhheGF4UXhBeDF4IXgheBF4AXfxd+F34XfRfVBWvobOht6G3obuhv6HDocehx6HLoc+h06HXodeh26HfoeOh56Hnoeuh76Hzofeh96L8Lfxd+F30XfBd8F3sXehd5F3gXeBd3F3YXdRd0F3QXcxdyF3EXcBdwF28XbhdtF2wXe+h76Hzofeh+6H/of+iA6IHoguiD6IPohOiF6Iboh+iH6IjoieiK6Ivoi+iM6I3oI/pvF24XbhdtF2wXaxdqF2oXaRdoF2cXZhdmF2UXZBdjF2IXYRdhF2AXXxdeF14XXRcZ+ovojOiN6I3ojuiP6JDokeiR6JLok+iU6JXoleiW6JfomOiZ6Jnomuib6Jzoneid6F8XXxdeF10XXBdbF1sXWhdZF1gXVxdXF1YXVRdUF1MXUxdSF1EXUBdPF08XThdNF6ALm+ib6Jzoneie6J/on+ig6KHoouij6KPopOil6Kbop+in6Kjoqeiq6Kvoq+is6K3o0wVPF04XTRdNF0wXSxdKF0kXSRdIF0cXRhdFF0UXRBdDF0IXQRdBF0AXPxc+Fz0XPRd87qvorOit6K3oruiv6LDoseix6LLos+i06LXotei26LfouOi56Lnouui76Lzovehe9D8XPxc+Fz0XPBc7FzsXOhc5FzgXNxc3FzYXNRc0FzMXMxcyFzEXMBcvFy8XLhctF/P/u+i76Lzovei+6L/ov+jA6MHowujD6MPoxOjF6Mbox+jH6MjoyejK6Mvoy+jM6M3oYxEvFy4XLRctFywXKxcqFykXKRcoFycXJhclFyUXJBcjFyIXIRchFyAXHxceFx0XUhHK6MvozOjN6M3ozujP6NDo0ejR6NLo0+jU6NXo1ejW6Nfo2OjZ6Nno2ujb6Nzo3ej//x8XHhceFx0XHBcbFxoXGhcZFxgXFxcWFxYXFRcUFxMXEhcSFxEXEBcPFw8XDhcNF2f02+jb6Nzo3eje6N/o3+jg6OHo4ujj6OPo5Ojl6Obo5+jn6Ojo6ejq6Ovo6+js6LHuEBcPFw4XDRcMFwwXCxcKFwkXCBcIFwcXBhcFFwQXBBcDFwIXARcAFwAX/xb+Fv0WtgXq6Ovo7Ojt6O3o7ujv6PDo8ejx6PLo8+j06PXo9ej26Pfo+Oj56Pno+uj76Pzo/eh/C/8W/hb+Fv0W/Bb7FvoW+hb5FvgW9xb2FvYW9Rb0FvMW8hbyFvEW8BbvFu4W7hbtFvro++j86Pzo/ej+6P/o/+gA6QHpAukD6QPpBOkF6QbpB+kH6QjpCekK6QvpC+kM6UL67xbvFu4W7RbsFusW6xbqFukW6BboFucW5hblFuQW5BbjFuIW4RbgFuAW3xbeFt0WOfoK6QvpDOkN6Q7pDukP6RDpEekR6RLpE+kU6RXpFekW6RfpGOkZ6RnpGukb6RzpHengFt8W3hbdFt0W3BbbFtoW2RbZFtgW1xbWFtYW1RbUFtMW0hbSFtEW0BbPFs4WzhZgCxrpG+kc6RzpHeke6R/pIOkg6SHpIukj6SPpJOkl6SbpJ+kn6SjpKekq6SvpK+ks6bMFzxbPFs4WzRbMFssWyxbKFskWyBbHFscWxhbFFsQWwxbDFsIWwRbAFsAWvxa+Fr0W3O4q6SvpLOkt6S7pLukv6TDpMeky6TLpM+k06TXpNek26TfpOOk56TnpOuk76TzpnfTAFr8Wvha9Fr0WvBa7FroWuRa5FrgWtxa2FrUWtRa0FrMWshaxFrEWsBavFq4Wrhbz/zrpO+k86TzpPek+6T/pQOlA6UHpQulD6UPpROlF6UbpR+lH6UjpSelK6UvpS+lM6QQRrxauFq4WrRasFqsWqxaqFqkWqBanFqcWphalFqQWoxajFqIWoRagFp8WnxaeFvMQSulK6UvpTOlN6U7pTulP6VDpUelS6VLpU+lU6VXpVelW6VfpWOlZ6VnpWulb6Vzp/v+gFp8WnhadFpwWnBabFpoWmRaYFpgWlxaWFpUWlRaUFpMWkhaRFpEWkBaPFo4Wjham9FrpW+lc6VzpXele6V/pYOlg6WHpYulj6WTpZOll6WbpZ+ln6Wjpaelq6Wvpa+kR75AWjxaOFo4WjRaMFosWihaKFokWiBaHFoYWhhaFFoQWgxaDFoIWgRaAFn8WfxZ+FpYFaulq6WvpbOlt6W7pbulv6XDpcely6XLpc+l06XXpdel26XfpeOl56Xnpeul76XzpPwt/Fn8WfhZ9FnwWfBZ7FnoWeRZ4FngWdxZ2FnUWdBZ0FnMWchZxFnEWcBZvFm4WbRZ56Xrpe+l86Xzpfel+6X/pgOmA6YHpgumD6YTphOmF6Ybph+mH6YjpiemK6Yvpi+li+nAWbxZuFm0WbRZsFmsWahZqFmkWaBZnFmYWZhZlFmQWYxZjFmIWYRa/9Irpi+mM6Y3pjumO6Y/pkOmR6ZHpkumT6ZTplemV6Zbpl+mY6ZjpzPRjFmIWYRZgFl8WXxZeFl0WXBZcFlsWWhZZFlgWWBZXFlYWVRZVFlQWXfqY6Zjpmema6Zvpm+mc6Z3pnumf6Z/poOmh6aLpoumj6aTppemm6TzvVRZVFlQWUxZSFlEWURZQFk8WThZOFk0WTBZLFkoWShZJFkgWRxZHFvX/peml6abpp+mo6anpqemq6avprOms6a3prumv6bDpsOmx6bLps+m06UgWRxZHFkYWRRZEFkMWQxZCFkEWQBZAFj8WPhY9FjwWPBY7FjoWORaGBbLps+mz6bTptem26bfpt+m46bnpuum66bvpvOm96b7pvum/6cDpwemsEDoWORY5FjgWNxY2FjYWNRY0FjMWMhYyFjEWMBYvFi8WLhYtFiwWEAu/6cDpwenB6cLpw+nE6cTpxenG6cfpyOnI6cnpyunL6cvpzOnN6c7pFgstFiwWKxYrFioWKRYoFigWJxYmFiUWJBYkFiMWIhYhFiEWIBYfFpQQzOnN6c7pzunP6dDp0enS6dLp0+nU6dXp1enW6dfp2OnZ6dnp2unb6YcFIBYfFh4WHRYdFhwWGxYaFhoWGRYYFhcWFhYWFhUWFBYTFhMWEhYRFtnp2unb6dzp3Ond6d7p3+nf6eDp4eni6ePp4+nk6eXp5unm6efp6On+/xMWEhYRFhAWDxYPFg4WDRYMFgwWCxYKFgkWCBYIFgcWBhYFFgUWBBZq7+fp6Onp6erp6unr6ezp7ent6e7p7+nw6fHp8eny6fPp9On06fXpfPoFFgUWBBYDFgIWARYBFgAW/xX+Ff4V/RX8FfsV+xX6FfkV+BX3FfcV9PT06fXp9un36ffp+On56frp++n76fzp/en+6f7p/+kA6gHqAuoC6gD1+BX3FfcV9hX1FfQV9BXzFfIV8RXwFfAV7xXuFe0V7RXsFesV6hXpFXj6AuoC6gPqBOoF6gXqBuoH6gjqCOoJ6grqC+oM6gzqDeoO6g/qD+qM7+sV6hXpFekV6BXnFeYV5hXlFeQV4xXiFeIV4RXgFd8V3xXeFd0V3BX1/w/qD+oQ6hHqEuoT6hPqFOoV6hbqFuoX6hjqGeoZ6hrqG+oc6h3qHereFd0V3BXbFdsV2hXZFdgV2BXXFdYV1RXUFdQV0xXSFdEV0RXQFc8VawUc6h3qHeoe6h/qIOog6iHqIuoj6iTqJOol6ibqJ+on6ijqKeoq6irqXBDQFc8VzhXNFc0VzBXLFcoVyhXJFcgVxxXHFcYVxRXEFcMVwxXCFdsKKeoq6irqK+os6i3qLuou6i/qMOox6jHqMuoz6jTqNeo16jbqN+o46uEKwxXCFcEVwBW/Fb8VvhW9FbwVvBW7FboVuRW5FbgVtxW2FbYVtRVEEDbqN+o46jjqOeo66jvqO+o86j3qPuo/6j/qQOpB6kLqQupD6kTqRepsBbUVtRW0FbMVshWyFbEVsBWvFa4VrhWtFawVqxWrFaoVqRWoFagVpxVD6kTqRepG6kbqR+pI6knqSepK6kvqTOpM6k3qTupP6lDqUOpR6lLq/v+oFacVpxWmFaUVpBWkFaMVohWhFaAVoBWfFZ4VnRWdFZwVmxWaFZoVuu9R6lLqU+pT6lTqVepW6lfqV+pY6lnqWupa6lvqXOpd6l3qXupf6pb6mxWaFZkVmRWYFZcVlhWWFZUVlBWTFZMVkhWRFZAVjxWPFY4VjRWMFSn1Xupf6mDqYeph6mLqY+pk6mTqZepm6mfqaOpo6mnqaupr6mvqbOo19Y4VjRWMFYsVixWKFYkViBWIFYcVhhWFFYUVhBWDFYIVghWBFYAVfxWS+mvqbOpt6m7qb+pv6nDqcepy6nLqc+p06nXqdep26nfqeOp46nnq2++BFYAVfxV+FX0VfRV8FXsVehV6FXkVeBV3FXcVdhV1FXQVdBVzFXIV9f956nnqeup76nzqfOp96n7qf+qA6oDqgeqC6oPqg+qE6oXqhuqG6ofqcxVzFXIVcRVwFXAVbxVuFW0VbBVsFWsVahVpFWkVaBVnFWYVZhVlFVEFhuqH6ofqiOqJ6orqiuqL6ozqjeqN6o7qj+qQ6pDqkeqS6pPqlOqU6gwQZRVlFWQVYxViFWIVYRVgFV8VXxVeFV0VXBVbFVsVWhVZFVgVWBWmCpPqlOqU6pXqluqX6pjqmOqZ6prqm+qb6pzqneqe6p7qn+qg6qHqoeqrClgVVxVXFVYVVRVUFVQVUxVSFVEVURVQFU8VThVOFU0VTBVLFUsV9Q+g6qHqouqi6qPqpOql6qXqpuqn6qjqqOqp6qrqq+qs6qzqrequ6q/qUQVLFUoVSRVJFUgVRxVGFUYVRRVEFUMVQxVCFUEVQBVAFT8VPhU9FT0Vrequ6q/qsOqw6rHqsuqz6rPqtOq16rbqtuq36rjqueq56rrqu+q86v3/PhU9FTwVOxU7FToVORU4FTgVNxU2FTUVNRU0FTMVMhUyFTEVMBUvFQnwu+q86r3qveq+6r/qwOrA6sHqwurD6sTqxOrF6sbqx+rH6sjqyeqw+jEVMBUvFS4VLhUtFSwVKxUrFSoVKRUoFScVJxUmFSUVJBUkFSMVIhVe9cjqyerK6svqy+rM6s3qzurO6s/q0OrR6tHq0urT6tTq1OrV6tbqavUjFSMVIhUhFSAVIBUfFR4VHRUdFRwVGxUaFRoVGRUYFRcVFxUWFRUVrfrW6tbq1+rY6tnq2era6tvq3Orc6t3q3urf6t/q4Orh6uLq4urj6irwFhUVFRUVFBUTFRIVEhURFRAVDxUPFQ4VDRUMFQwVCxUKFQkVCRUIFfX/4+rj6uTq5erm6ubq5+ro6unq6erq6uvq7Ors6u3q7urv6vDq8Orx6gkVCBUHFQcVBhUFFQQVBBUDFQIVARUBFQAV/xT+FP4U/RT8FPsU+xQ2BfDq8erx6vLq8+r06vTq9er26vfq9+r46vnq+ur66vvq/Or96v3q/uq8D/sU+hT6FPkU+BT3FPYU9hT1FPQU8xTzFPIU8RTwFPAU7xTuFO0UcQr96v7q/ur/6gDrAesC6wLrA+sE6wXrBesG6wfrCOsI6wnrCusL6wvrdgruFO0U7BTsFOsU6hTpFOkU6BTnFOYU5hTlFOQU4xTjFOIU4RTgFKUPCusL6wzrDOsN6w7rD+sP6xDrEesS6xLrE+sU6xXrFesW6xfrGOsY6zYF4RTgFN8U3hTeFN0U3BTbFNsU2hTZFNgU2BTXFNYU1RTVFNQU0xTSFBfrGOsZ6xrrGusb6xzrHesd6x7rH+sg6yDrIesi6yPrI+sk6yXrJuv9/9MU0xTSFNEU0BTQFM8UzhTNFM0UzBTLFMoUyhTJFMgUxxTHFMYUxRRZ8CXrJusn6yfrKOsp6yrrKusr6yzrLest6y7rL+sw6zDrMesy6zPry/rGFMUUxRTEFMMUwhTCFMEUwBS/FL8UvhS9FLwUvBS7FLoUuhS5FLgUk/Uy6zPrNOs16zXrNus36zjrOOs56zrrO+s76zzrPes+6z7rP+tA65/1uRS4FLgUtxS2FLUUtRS0FLMUshSyFLEUsBSvFK8UrhStFKwUrBSrFMf6QOtA60HrQutD60PrROtF60brRutH60jrSetJ60rrS+tM60zrTet68KwUqxSqFKoUqRSoFKcUpxSmFKUUpBSkFKMUohShFKEUoBSfFJ4UnhT1/03rTetO60/rUOtQ61HrUutT61PrVOtV61brVutX61jrWetZ61rrW+ufFJ4UnRScFJwUmxSaFJkUmRSYFJcUlhSWFJUUlBSTFJMUkhSRFJAUGwVa61vrW+tc613rXute61/rYOth62HrYutj62TrZOtl62brZ+tn62jrbA+RFJAUjxSOFI4UjRSMFIsUixSKFIkUiBSIFIcUhhSGFIUUhBSDFDwKZ+to62nraetq62vrbOts623rbutu62/rcOtx63Hrcutz63TrdOt160EKgxSDFIIUgRSBFIAUfxR+FH4UfRR8FHsUexR6FHkUeBR4FHcUdhRVD3Trdet263brd+t463nreet663vrfOt8633rfut/63/rgOuB64LrgusbBXYUdhR1FHQUcxRzFHIUcRRwFHAUbxRuFG0UbRRsFGsUahRqFGkUaBSB64Lrg+uE64TrheuG64frh+uI64nriuuK64vrjOuN643rjuuP64/r/f9pFGgUaBRnFGYUZRRlFGQUYxRiFGIUYRRgFF8UXxReFF0UXRRcFFsUqPCP65DrkeuS65Lrk+uU65TrleuW65frl+uY65nrmuua65vrnOud6+X6XBRbFFoUWhRZFFgUVxRXFFYUVRRUFFQUUxRSFFIUURRQFE8UTxROFMj1nOud657rn+uf66Droeui66Lro+uk66Xrpeum66frqOuo66nrquvU9U8UThRNFEwUTBRLFEoUShRJFEgURxRHFEYURRREFEQUQxRCFEEUQRTi+qrrquur66zrreut667rr+uw67Drseuy67Lrs+u067Xrteu267fryfBBFEEUQBQ/FD8UPhQ9FDwUPBQ7FDoUORQ5FDgUNxQ2FDYUNRQ0FDMU9f+367jruOu567rruuu767zrveu9677rv+vA68DrwevC68Prw+vE68XrNBQ0FDMUMhQxFDEUMBQvFC4ULhQtFCwUKxQrFCoUKRQpFCgUJxQmFAEFxOvF68XrxuvH68jryOvJ68rry+vL68zrzevO687rz+vQ69Dr0evS6x0PJhQmFCUUJBQjFCMUIhQhFCAUIBQfFB4UHhQdFBwUGxQbFBoUGRQGCtHr0uvT69Pr1OvV69br1uvX69jr2OvZ69rr2+vb69zr3eve697r3+sLChkUGBQYFBcUFhQVFBUUFBQTFBMUEhQRFBAUEBQPFA4UDRQNFAwUBg/e69/r4Ovh6+Hr4uvj6+Pr5Ovl6+br5uvn6+jr6evp6+rr6+vs6+zrAQUMFAsUCxQKFAkUCBQIFAcUBhQFFAUUBBQDFAIUAhQBFAAUABT/E/4T6+vs6+3r7uvu6+/r8Ovx6/Hr8uvz6/Tr9Ov16/br9uv36/jr+ev56/3//xP+E/0T/RP8E/sT+hP6E/kT+BP3E/cT9hP1E/UT9BPzE/IT8hPxE/jw+ev66/vr/Ov86/3r/uv/6//rAOwB7AHsAuwD7ATsBOwF7AbsB+z/+vIT8RPwE+8T7xPuE+0T7BPsE+sT6hPqE+kT6BPnE+cT5hPlE+QT5BP99QfsB+wI7AnsCewK7AvsDOwM7A3sDuwP7A/sEOwR7BLsEuwT7BTsCfbkE+QT4xPiE+ET4RPgE98T3xPeE90T3BPcE9sT2hPZE9kT2BPXE9cT/PoU7BTsFewW7BfsF+wY7BnsGuwa7BvsHOwc7B3sHuwf7B/sIOwh7Bjx1xPWE9YT1RPUE9QT0xPSE9ET0RPQE88TzhPOE80TzBPME8sTyhPJE/T/Iewi7CLsI+wk7CXsJewm7CfsJ+wo7CnsKuwq7CvsLOwt7C3sLuwv7MoTyRPJE8gTxxPGE8YTxRPEE8MTwxPCE8ETwRPAE78TvhO+E70TvBPmBC7sL+ww7DDsMewy7DLsM+w07DXsNew27DfsOOw47DnsOuw67DvsPOzNDrwTuxO7E7oTuRO4E7gTtxO2E7YTtRO0E7MTsxOyE7ETsBOwE68T0Qk77DzsPew97D7sP+xA7EDsQexC7EPsQ+xE7EXsRexG7EfsSOxI7Ens1gmvE64TrROtE6wTqxOrE6oTqROoE6gTpxOmE6UTpROkE6MToxOiE7YOSOxJ7ErsS+xL7EzsTexN7E7sT+xQ7FDsUexS7FPsU+xU7FXsVexW7OYEohOhE6AToBOfE54TnROdE5wTmxOaE5oTmROYE5gTlxOWE5UTlROUE1bsVuxX7FjsWOxZ7FrsW+xb7FzsXexe7F7sX+xg7GDsYexi7GPsY+z9/5QTlBOTE5ITkhORE5ATjxOPE44TjRONE4wTixOKE4oTiROIE4cThxNH8WPsZOxl7GbsZuxn7Gjsaexp7Grsa+xr7Gzsbexu7G7sb+xw7HHsGvuHE4cThhOFE4QThBODE4ITghOBE4ATfxN/E34TfRN8E3wTexN6E3oTMvZx7HHscuxz7HTsdOx17Hbsdux37Hjseex57Hrse+x87Hzsfex+7D32ehN5E3kTeBN3E3YTdhN1E3QTdBNzE3ITcRNxE3ATbxNvE24TbRNsExf7fux/7H/sgOyB7IHsguyD7ITshOyF7IbshuyH7IjsieyJ7Irsi+xo8W0TbBNrE2sTahNpE2kTaBNnE2YTZhNlE2QTZBNjE2ITYRNhE2ATXxP1/4vsjOyM7I3sjuyP7I/skOyR7JHskuyT7JTslOyV7Jbsl+yX7JjsmexgE18TXhNeE10TXBNbE1sTWhNZE1kTWBNXE1YTVhNVE1QTVBNTE1ITzASY7Jnsmuya7JvsnOyd7J3snuyf7J/soOyh7KLsouyj7KTspOyl7KbsfQ5SE1ETUBNQE08TThNNE00TTBNLE0sTShNJE0gTSBNHE0YTRhNFE5wJpeym7KfsqOyo7Knsquyq7KvsrOyt7K3sruyv7K/ssOyx7LLssuyz7KEJRRNEE0MTQhNCE0ETQBNAEz8TPhM9Ez0TPBM7EzsTOhM5EzgTOBNmDrPss+y07LXstey27LfsuOy47Lnsuuy67LvsvOy97L3svuy/7L/swOzLBDcTNxM2EzUTNRM0EzMTMhMyEzETMBMwEy8TLhMtEy0TLBMrEysTKhPA7MDswezC7MPsw+zE7MXsxezG7MfsyOzI7MnsyuzK7MvszOzN7M3s/P8qEyoTKRMoEycTJxMmEyUTJRMkEyMTIhMiEyETIBMgEx8THhMdEx0Tl/HO7M7sz+zQ7NDs0ezS7NPs0+zU7NXs1ezW7Nfs2OzY7Nns2uza7DT7HRMcExwTGxMaExkTGRMYExcTFxMWExUTFBMUExMTEhMSExETEBMPE2f22+zc7Nzs3eze7N7s3+zg7OHs4ezi7OPs4+zk7OXs5uzm7Ofs6Oxy9hATDxMOEw4TDRMMEwwTCxMKEwkTCRMIEwcTBxMGEwUTBBMEEwMTAhMx++js6ezp7Ors6+zs7Ozs7ezu7O7s7+zw7PHs8ezy7PPs8+z07PXst/EDEwITARMBEwAT/xL+Ev4S/RL8EvwS+xL6EvkS+RL4EvcS9xL2EvUS9f/17Pbs9+z37Pjs+ez57Prs++z87Pzs/ez+7P7s/+wA7QHtAe0C7QPt9RL1EvQS8xLzEvIS8RLwEvAS7xLuEu4S7RLsEuwS6xLqEukS6RLoErEEAu0D7QTtBO0F7QbtB+0H7QjtCe0J7QrtC+0M7QztDe0O7Q7tD+0Q7S0O6BLnEuYS5RLlEuQS4xLjEuIS4RLgEuAS3xLeEt4S3RLcEtwS2xJnCRDtEO0R7RLtEu0T7RTtFO0V7RbtF+0X7RjtGe0Z7RrtG+0c7RztHe1sCdoS2hLZEtgS2BLXEtYS1RLVEtQS0xLTEtIS0RLQEtASzxLOEs4SFw4d7R3tHu0f7SDtIO0h7SLtIu0j7STtJO0l7SbtJ+0n7SjtKe0p7SrtsQTNEswSzBLLEsoSyhLJEsgSyBLHEsYSxRLFEsQSwxLDEsISwRLAEsASKu0r7SvtLO0t7S3tLu0v7TDtMO0x7TLtMu0z7TTtNO017TbtN+037fz/wBK/Er8SvhK9ErwSvBK7EroSuhK5ErgSuBK3ErYStRK1ErQSsxKzEubxOO047TntOu077TvtPO097T3tPu0/7UDtQO1B7ULtQu1D7UTtRO1O+7MSshKxErESsBKvEq8SrhKtEqwSrBKrEqoSqhKpEqgSqBKnEqYSpRKc9kXtRu1G7UftSO1I7UntSu1L7UvtTO1N7U3tTu1P7VDtUO1R7VLtp/amEqUSpBKjEqMSohKhEqESoBKfEp8SnhKdEpwSnBKbEpoSmhKZEpgSTPtS7VPtVO1U7VXtVu1W7VftWO1Y7VntWu1b7VvtXO1d7V3tXu1f7QfymBKYEpcSlhKWEpUSlBKTEpMSkhKREpESkBKPEo8SjhKNEowSjBKLEvX/X+1g7WHtYe1i7WPtZO1k7WXtZu1m7WftaO1o7Wntau1r7WvtbO1t7YsSixKKEokSiBKIEocShhKGEoUShBKDEoMSghKBEoESgBJ/En8SfhKXBGztbe1u7W/tb+1w7XHtce1y7XPtdO107XXtdu127XfteO147Xnteu3eDX0SfRJ8EnsSexJ6EnkSeBJ4EncSdhJ2EnUSdBJ0EnMSchJxEnESMgl67Xrte+187Xztfe1+7X/tf+2A7YHtge2C7YPtg+2E7YXthu2G7YftNglwEm8SbxJuEm0SbRJsEmsSaxJqEmkSaBJoEmcSZhJmEmUSZBJkEscNh+2I7Yjtie2K7Yrti+2M7Yztje2O7Y/tj+2Q7ZHtke2S7ZPtk+2U7ZYEYxJiEmISYRJgEl8SXxJeEl0SXRJcElsSWxJaElkSWBJYElcSVhJWEpTtle2V7Zbtl+2Y7Zjtme2a7Zrtm+2c7Zztne2e7Z/tn+2g7aHtoe38/1YSVRJUElQSUxJSElISURJQEk8STxJOEk0STRJMEksSSxJKEkkSSRI28qLto+2j7aTtpe2l7abtp+2o7ajtqe2q7artq+2s7aztre2u7a7taftJEkgSRxJGEkYSRRJEEkQSQxJCEkISQRJAEkASPxI+Ej0SPRI8EjsS0vav7bDtse2x7bLts+2z7bTtte217bbtt+237bjtue267brtu+287dz2OxI7EjoSORI5EjgSNxI3EjYSNRI0EjQSMxIyEjISMRIwEjASLxIuEmb7vO297b7tvu2/7cDtwO3B7cLtw+3D7cTtxe3F7cbtx+3H7cjtye1W8i4SLRItEiwSKxIrEioSKRIpEigSJxInEiYSJRIkEiQSIxIiEiISIRL1/8ntyu3L7cztzO3N7c7tzu3P7dDt0O3R7dLt0+3T7dTt1e3V7dbt1+0hEiASIBIfEh4SHhIdEhwSGxIbEhoSGRIZEhgSFxIXEhYSFRIVEhQSfATX7dft2O3Z7dnt2u3b7dzt3O3d7d7t3u3f7eDt4O3h7eLt4u3j7eTtjg0TEhISEhIREhASEBIPEg4SDhINEgwSDBILEgoSCRIJEggSBxIHEv0I5O3l7eXt5u3n7eft6O3p7ent6u3r7evt7O3t7e7t7u3v7fDt8O3x7QEJBhIFEgUSBBIDEgISAhIBEgASABL/Ef4R/hH9EfwR/BH7EfoR+RF4DfHt8u3y7fPt9O317fXt9u337fft+O357fnt+u377fvt/O397f7t/u17BPkR+BH3EfcR9hH1EfUR9BHzEfMR8hHxEfAR8BHvEe4R7hHtEewR7BH+7f/tAO4A7gHuAu4C7gPuBO4E7gXuBu4H7gfuCO4J7gnuCu4L7gvu/P/sEesR6hHpEekR6BHnEecR5hHlEeUR5BHjEeMR4hHhEeER4BHfEd4RhvIM7g3uDe4O7g/uEO4Q7hHuEu4S7hPuFO4U7hXuFu4W7hfuGO4Z7oP73hHeEd0R3BHcEdsR2hHaEdkR2BHXEdcR1hHVEdUR1BHTEdMR0hHREQf3Ge4a7hvuG+4c7h3uHe4e7h/uIO4g7iHuIu4i7iPuJO4k7iXuJu4R99ER0BHQEc8RzhHOEc0RzBHMEcsRyhHKEckRyBHIEccRxhHGEcURxBGB+ybuJ+4o7inuKe4q7ivuK+4s7i3uLe4u7i/uL+4w7jHuMe4y7jPupvLEEcMRwxHCEcERwRHAEb8RvhG+Eb0RvBG8EbsRuhG6EbkRuBG4EbcR9f807jTuNe427jbuN+447jjuOe467jvuO+487j3uPe4+7j/uP+5A7kHutxG2EbURtRG0EbMRsxGyEbERsRGwEa8RrxGuEa0RrRGsEasRqhGqEWIEQe5C7kLuQ+5E7kTuRe5G7kbuR+5I7kjuSe5K7kruS+5M7k3uTe5O7j4NqRGoEagRpxGmEaYRpRGkEaMRoxGiEaERoRGgEZ8RnxGeEZ0RnRHICE7uT+5P7lDuUe5R7lLuU+5U7lTuVe5W7lbuV+5Y7ljuWe5a7lruW+7MCJwRmxGaEZoRmRGYEZgRlxGWEZYRlRGUEZQRkxGSEZIRkRGQEY8RKA1b7lzuXe5d7l7uX+5f7mDuYe5h7mLuY+5j7mTuZe5m7mbuZ+5o7mjuYQSPEY4RjRGNEYwRixGKEYoRiRGIEYgRhxGGEYYRhRGEEYQRgxGCEYIRaO5p7mruau5r7mzube5t7m7ub+5v7nDuce5x7nLuc+5z7nTude517vz/gRGBEYARfxF/EX4RfRF9EXwRexF7EXoReRF5EXgRdxF2EXYRdRF0EdXydu537njueO557nrueu577nzufO597n7ufu5/7oDuge6B7oLug+6e+3QRcxFzEXIRcRFxEXARbxFvEW4RbRFtEWwRaxFrEWoRaRFpEWgRZxE894TuhO6F7obuhu6H7ojuiO6J7oruiu6L7ozujO6N7o7uju6P7pDuRvdnEWYRZhFlEWQRZBFjEWIRYhFhEWARYBFfEV4RXRFdEVwRWxFbEVoRnPuR7pHuku6T7pPulO6V7pXulu6X7pfumO6Z7prumu6b7pzunO6d7vXyWhFZEVgRWBFXEVYRVhFVEVQRVBFTEVIRUhFREVARUBFPEU4RThFNEfX/nu6f7p/uoO6h7qHuou6j7qPupO6l7qXupu6n7qfuqO6p7qnuqu6r7k0RTBFLEUsRShFJEUkRSBFHEUcRRhFFEUQRRBFDEUIRQhFBEUARQBFHBKvurO6s7q3uru6u7q/usO6w7rHusu6z7rPutO617rXutu637rfuuO7vDD8RPhE9ET0RPBE7ETsROhE5ETkROBE3ETcRNhE1ETURNBEzETMRkwi47rnuuu667rvuvO687r3uvu6+7r/uwO7A7sHuwu7C7sPuxO7E7sXulwgyETERMBEwES8RLhEuES0RLBErESsRKhEpESkRKBEnEScRJhElEdkMxe7G7sfux+7I7snuyu7K7svuzO7M7s3uzu7O7s/u0O7Q7tHu0u7S7kYEJBEkESMRIhEiESERIBEgER8RHhEeER0RHBEcERsRGhEaERkRGBEYEdPu0+7U7tXu1e7W7tfu1+7Y7tnu2e7a7tvu2+7c7t3u3e7e7t/u3+78/xcRFxEWERURFBEUERMREhESEREREBEQEQ8RDhEOEQ0RDBEMEQsRChEl8+Hu4e7i7uPu4+7k7uXu5e7m7ufu5+7o7unu6e7q7uvu6+7s7u3uuPsKEQkRCREIEQcRBxEGEQURBREEEQMRAxECEQERAREAEf8Q/xD+EP0Qcffu7u7u7+7w7vDu8e7y7vLu8+707vTu9e727vbu9+747vju+e767nv3/RD8EPsQ+xD6EPkQ+RD4EPcQ9xD2EPUQ9RD0EPMQ8xDyEPEQ8RDwELb7++787vzu/e7+7v7u/+4A7wDvAe8C7wLvA+8E7wTvBe8G7wbvB+9F8/AQ7xDuEO4Q7RDsEOwQ6xDqEOoQ6RDoEOgQ5xDmEOYQ5RDkEOQQ4xD1/wjvCe8J7wrvC+8L7wzvDe8N7w7vD+8P7xDvEe8R7xLvE+8U7xTvFe/iEOIQ4RDgEOAQ3xDeEN4Q3RDcENwQ2xDaENoQ2RDYENgQ1xDWENYQLQQV7xbvF+8X7xjvGe8Z7xrvG+8b7xzvHe8d7x7vH+8f7yDvIe8h7yLvnwzVENQQ0xDTENIQ0RDRENAQzxDPEM4QzRDNEMwQyxDLEMoQyRDJEF4II+8j7yTvJe8l7ybvJ+8n7yjvKe8p7yrvK+8r7yzvLe8t7y7vL+8v72IIxxDHEMYQxRDFEMQQwxDDEMIQwRDBEMAQvxC/EL4QvRC9ELwQuxCJDDDvMO8x7zLvMu8z7zTvNO817zbvNu837zjvOO857zrvOu877zzvPO8sBLoQuhC5ELgQuBC3ELYQthC1ELQQtBCzELIQshCxELAQsBCvEK4QrhA97z7vPu8/70DvQO9B70LvQu9D70TvRO9F70bvRu9H70jvSO9J70rv/P+tEKwQrBCrEKoQqhCpEKgQqBCnEKYQphClEKQQpBCjEKIQohChEKAQdfNL70vvTO9N703vTu9P70/vUO9R71HvUu9T71PvVO9V71XvVu9X79P7oBCfEJ8QnhCdEJ0QnBCbEJsQmhCZEJkQmBCXEJcQlhCVEJUQlBCTEKb3WO9Z71nvWu9b71vvXO9d713vXu9f71/vYO9h72HvYu9j72PvZO+w95MQkhCREJEQkBCPEI8QjhCNEI0QjBCLEIsQihCJEIkQiBCHEIcQhhDR+2XvZu9m72fvaO9o72nvau9q72vvbO9s723vbu9u72/vcO9w73HvlPOFEIUQhBCDEIMQghCCEIEQgBCAEH8QfhB+EH0QfBB8EHsQehB6EHkQ9f9y73PvdO9073Xvdu9273fveO9473nveu9673vvfO98733vfu9+73/veBB4EHcQdhB2EHUQdBB0EHMQchByEHEQcBBwEG8QbhBuEG0QbBBsEBMEgO+A74Hvgu+C74PvhO+E74Xvhe+G74fvh++I74nvie+K74vvi++M708MahBqEGkQaBBoEGcQZhBmEGUQZRBkEGMQYxBiEGEQYRBgEF8QXxAqCI3vje+O74/vj++Q75Hvke+S75Pvk++U75Xvle+W75fvl++Y75nvme8tCF0QXRBcEFsQWxBaEFkQWRBYEFcQVxBWEFUQVRBUEFMQUxBSEFEQOgya75vvm++c753vne+e75/vn++g76Hvoe+i76Pvo++k76Tvpe+m76bvEQRQEE8QTxBOEE0QTRBMEEsQSxBKEEoQSRBIEEgQRxBGEEYQRRBEEEQQp++o76jvqe+q76rvq++s76zvre+u767vr++w77Dvse+y77Lvs++07/z/QxBCEEIQQRBAEEAQPxA+ED4QPRA8EDwQOxA6EDoQORA4EDgQNxA2EMXzte+277bvt++477jvue+677rvu++877zvve++777vv+/A78Dvwe/t+zYQNRA0EDQQMxAyEDIQMRAwEDAQLxAvEC4QLRAtECwQKxArECoQKRDb98Lvw+/E78Tvxe/F78bvx+/H78jvye/J78rvy+/L78zvze/N787v5fcpECgQJxAnECYQJRAlECQQIxAjECIQIRAhECAQHxAfEB4QHRAdEBwQ7PvP79Dv0e/R79Lv0+/T79Tv1e/V79bv1+/X79jv2e/Z79rv2+/b7+TzGxAbEBoQGRAZEBgQFxAXEBYQFRAVEBQQFBATEBIQEhAREBAQEBAPEPX/3e/d797v3+/f7+Dv4e/h7+Lv4u/j7+Tv5O/l7+bv5u/n7+jv6O/p7w4QDRANEAwQDBALEAoQChAJEAgQCBAHEAYQBhAFEAQQBBADEAIQAhD4A+rv6u/r7+zv7O/t7+7v7u/v7/Dv8O/x7/Lv8u/z7/Tv9O/17/bv9u8ADAAQABD/D/4P/g/9D/wP/A/7D/oP+g/5D/kP+A/3D/cP9g/1D/UP9Qf37/jv+O/57/rv+u/77/zv/O/97/7v/u//7//vAPAB8AHwAvAD8APw+AfzD/IP8g/xD/EP8A/vD+8P7g/tD+0P7A/rD+sP6g/pD+kP6A/nD+oLBPAF8AbwBvAH8AfwCPAJ8AnwCvAL8AvwDPAN8A3wDvAP8A/wEPAR8PYD5g/lD+UP5A/jD+MP4g/hD+EP4A/fD98P3g/eD90P3A/cD9sP2g/aDxHwEvAT8BPwFPAV8BXwFvAX8BfwGPAZ8BnwGvAa8BvwHPAc8B3wHvD8/9kP2A/XD9cP1g/WD9UP1A/UD9MP0g/SD9EP0A/QD88Pzg/OD80PzA8U9B/wIPAh8CHwIvAj8CPwJPAk8CXwJvAm8CfwKPAo8CnwKvAq8CvwCPzMD8sPyg/KD8kPyA/ID8cPxg/GD8UPxA/ED8MPww/CD8EPwQ/AD78PEfgs8C3wLvAu8C/wMPAw8DHwMvAy8DPwNPA08DXwNvA28DfwN/A48Br4vg++D70PvA+8D7sPuw+6D7kPuQ+4D7cPtw+2D7UPtQ+0D7MPsw+yDwb8OvA68DvwPPA88D3wPvA+8D/wP/BA8EHwQfBC8EPwQ/BE8EXwRfAz9LEPsQ+wD68Prw+uD60PrQ+sD6sPqw+qD6kPqQ+oD6gPpw+mD6YPpQ/1/0fwSPBI8EnwSfBK8EvwS/BM8E3wTfBO8E/wT/BQ8FHwUfBS8FLwU/CkD6MPow+iD6EPoQ+gD6APnw+eD54PnQ+cD5wPmw+aD5oPmQ+YD5gP3gNU8FXwVfBW8FfwV/BY8FnwWfBa8FvwW/Bc8FzwXfBe8F7wX/Bg8GDwsAuWD5YPlQ+UD5QPkw+SD5IPkQ+QD5APjw+OD44PjQ+ND4wPiw+LD8AHYfBi8GPwY/Bk8GTwZfBm8GbwZ/Bo8GjwafBq8Grwa/Bs8GzwbfBt8MMHiQ+ID4gPhw+GD4YPhQ+FD4QPgw+DD4IPgQ+BD4APfw9/D34PfQ+bC27wb/Bw8HDwcfBy8HLwc/B08HTwdfB28Hbwd/B38HjwefB58Hrwe/DcA3wPew97D3oPeQ95D3gPdw93D3YPdQ91D3QPdA9zD3IPcg9xD3APcA988HzwffB+8H7wf/CA8IDwgfCB8ILwg/CD8ITwhfCF8Ibwh/CH8Ijw/P9vD24PbQ9tD2wPaw9rD2oPag9pD2gPaA9nD2YPZg9lD2QPZA9jD2MPZPSK8Irwi/CL8IzwjfCN8I7wj/CP8JDwkfCR8JLwkvCT8JTwlPCV8CL8YQ9hD2APYA9fD14PXg9dD1wPXA9bD1oPWg9ZD1kPWA9XD1cPVg9VD0b4l/CX8JjwmfCZ8Jrwm/Cb8JzwnPCd8J7wnvCf8KDwoPCh8KLwovBP+FQPVA9TD1IPUg9RD1APUA9PD08PTg9ND00PTA9LD0sPSg9JD0kPSA8h/KTwpfCl8KbwpvCn8KjwqPCp8KrwqvCr8KzwrPCt8K7wrvCv8K/wg/RHD0YPRg9FD0UPRA9DD0MPQg9BD0EPQA8/Dz8PPg8+Dz0PPA88DzsP9v+x8LLwsvCz8LTwtPC18LbwtvC38LjwuPC58LnwuvC78LvwvPC98L3wOg85DzkPOA83DzcPNg81DzUPNA80DzMPMg8yDzEPMA8wDy8PLg8uD8MDvvC/8MDwwPDB8MLwwvDD8MPwxPDF8MXwxvDH8MfwyPDJ8MnwyvDK8GALLA8rDysPKg8qDykPKA8oDycPJg8mDyUPJA8kDyMPIw8iDyEPIQ+LB8zwzPDN8M3wzvDP8M/w0PDR8NHw0vDT8NPw1PDU8NXw1vDW8Nfw2PCOBx8PHg8eDx0PHA8cDxsPGg8aDxkPGQ8YDxcPFw8WDxUPFQ8UDxQPTAvZ8Nnw2vDb8Nvw3PDd8N3w3vDe8N/w4PDg8OHw4vDi8OPw5PDk8OXwwQMSDxEPEA8QDw8PDw8ODw0PDQ8MDwsPCw8KDwoPCQ8IDwgPBw8GDwYP5vDn8Ofw6PDp8Onw6vDq8Ovw7PDs8O3w7vDu8O/w7/Dw8PHw8fDy8Pz/BQ8EDwMPAw8CDwEPAQ8AD/8O/w7+Dv4O/Q78DvwO+w76DvoO+Q75DrT09PD08PXw9vD28Pfw+PD48Pnw+fD68Pvw+/D88P3w/fD+8P/w//A9/PcO9w72DvUO9Q70DvQO8w7yDvIO8Q7wDvAO7w7vDu4O7Q7tDuwO6w57+AHxAvEC8QPxBPEE8QXxBfEG8QfxB/EI8QnxCfEK8QrxC/EM8QzxhPjqDuoO6Q7oDugO5w7mDuYO5Q7lDuQO4w7jDuIO4Q7hDuAO3w7fDt4OPPwO8Q/xD/EQ8RHxEfES8RPxE/EU8RXxFfEW8RbxF/EY8RjxGfEa8dP03Q7cDtwO2w7aDtoO2Q7ZDtgO1w7XDtYO1Q7VDtQO1A7TDtIO0g7RDvb/G/Ec8R3xHfEe8R/xH/Eg8SDxIfEi8SLxI/Ek8STxJfEl8SbxJ/En8dAOzw7PDs4OzQ7NDswOyw7LDsoOyg7JDsgOyA7HDsYOxg7FDsUOxA6pAynxKfEq8SvxK/Es8SzxLfEu8S7xL/Ew8TDxMfEx8TLxM/Ez8TTxNfERC8IOwQ7BDsAOvw6/Dr4Ovg69DrwOvA67DroOug65DrkOuA63DrcOVgc28TbxN/E48TjxOfE68TrxO/E78TzxPfE98T7xP/E/8UDxQPFB8ULxWQe1DrQOtA6zDrIOsg6xDrAOsA6vDq8Org6tDq0OrA6rDqsOqg6qDvwKQ/FE8UTxRfFG8UbxR/FH8UjxSfFJ8UrxS/FL8UzxTPFN8U7xTvFP8acDqA6nDqYOpg6lDqQOpA6jDqMOog6hDqEOoA6gDp8Ong6eDp0OnA6cDlDxUfFS8VLxU/FT8VTxVfFV8VbxV/FX8VjxWPFZ8VrxWvFb8VvxXPH8/5oOmg6ZDpkOmA6XDpcOlg6VDpUOlA6UDpMOkg6SDpEOkA6QDo8Ojw4E9V7xX/Ff8WDxYfFh8WLxYvFj8WTxZPFl8WbxZvFn8WfxaPFp8WnxV/yNDo0OjA6LDosOig6KDokOiA6IDocOhg6GDoUOhQ6EDoMOgw6CDoEOsPhr8WzxbfFt8W7xbvFv8XDxcPFx8XLxcvFz8XPxdPF18XXxdvF28bn4gA5/Dn8Ofg5+Dn0OfA58DnsOeg56DnkOeQ54DncOdw52DnYOdQ50Dlb8efF58XrxevF78XzxfPF98X3xfvF/8X/xgPGB8YHxgvGC8YPxhPEi9XMOcg5yDnEOcA5wDm8Obw5uDm0ObQ5sDmsOaw5qDmoOaQ5oDmgOZw72/4bxhvGH8YjxiPGJ8YnxivGL8YvxjPGN8Y3xjvGO8Y/xkPGQ8ZHxkvFmDmUOZA5kDmMOYw5iDmEOYQ5gDmAOXw5eDl4OXQ5cDlwOWw5bDloOjwOT8ZTxlPGV8ZXxlvGX8ZfxmPGZ8ZnxmvGa8ZvxnPGc8Z3xnfGe8Z/xwQpYDlcOVw5WDlUOVQ5UDlQOUw5SDlIOUQ5RDlAOTw5PDk4OTQ5NDiEHoPGh8aHxovGj8aPxpPGk8aXxpvGm8afxqPGo8anxqfGq8avxq/Gs8SQHSw5KDkkOSQ5IDkgORw5GDkYORQ5FDkQOQw5DDkIOQQ5BDkAOQA6tCq3xrvGv8a/xsPGw8bHxsvGy8bPxtPG08bXxtfG28bfxt/G48bjxufGMAz4OPQ48DjwOOw46DjoOOQ45DjgONw43DjYONg41DjQONA4zDjIOMg678bvxvPG88b3xvvG+8b/xwPHA8cHxwfHC8cPxw/HE8cTxxfHG8cbx/P8wDjAOLw4uDi4OLQ4tDiwOKw4rDioOKg4pDigOKA4nDicOJg4lDiUOU/XI8cnxyvHK8cvxy/HM8c3xzfHO8c/xz/HQ8dDx0fHS8dLx0/HT8XL8Iw4jDiIOIQ4hDiAOHw4fDh4OHg4dDhwOHA4bDhsOGg4ZDhkOGA4YDub41vHW8dfx1/HY8dnx2fHa8dvx2/Hc8dzx3fHe8d7x3/Hf8eDx4fHv+BYOFQ4VDhQOFA4TDhIOEg4RDhAOEA4PDg8ODg4NDg0ODA4MDgsOCg5x/OPx4/Hk8eXx5fHm8efx5/Ho8ejx6fHq8erx6/Hr8ezx7fHt8e7xcvUJDggOCA4HDgYOBg4FDgUOBA4DDgMOAg4BDgEOAA4ADv8N/g3+Df0N9v/w8fHx8fHy8fLx8/H08fTx9fH28fbx9/H38fjx+fH58frx+vH78fzx/A37DfoN+g35DfkN+A33DfcN9g32DfUN9A30DfMN8g3yDfEN8Q3wDXQD/fH+8f7x//EA8gDyAfIC8gLyA/ID8gTyBfIF8gbyBvIH8gjyCPIJ8nIK7g3tDe0N7A3rDesN6g3qDekN6A3oDecN5w3mDeUN5Q3kDeMN4w3sBgryC/IM8gzyDfIO8g7yD/IP8hDyEfIR8hLyEvIT8hTyFPIV8hXyFvLvBuEN4A3fDd8N3g3eDd0N3A3cDdsN2w3aDdkN2Q3YDdgN1w3WDdYNXQoY8hjyGfIa8hryG/Ib8hzyHfId8h7yHvIf8iDyIPIh8iHyIvIj8iPycgPTDdMN0g3SDdEN0A3QDc8Nzw3ODc0NzQ3MDcwNyw3KDcoNyQ3JDcgNJfIm8ibyJ/In8ijyKfIp8iryKvIr8izyLPIt8i3yLvIv8i/yMPIw8vz/xg3GDcUNxA3EDcMNww3CDcENwQ3ADcANvw2+Db4NvQ29DbwNuw27DaP1M/Iz8jTyNfI18jbyNvI38jjyOPI58jnyOvI78jvyPPI88j3yPvKM/LkNuA24DbcNtw22DbUNtQ20DbQNsw2yDbINsQ2xDbANrw2vDa4Nrg0b+UDyQfJB8kLyQvJD8kTyRPJF8kXyRvJH8kfySPJI8knySvJK8kvyJPmsDasNqw2qDakNqQ2oDagNpw2mDaYNpQ2lDaQNow2jDaINog2hDaANjPxN8k7yTvJP8lDyUPJR8lHyUvJT8lPyVPJU8lXyVvJW8lfyV/JY8sH1nw2eDZ4NnQ2cDZwNmw2bDZoNmQ2ZDZgNmA2XDZYNlg2VDZUNlA2TDfb/WvJb8lzyXPJd8l3yXvJf8l/yYPJg8mHyYvJi8mPyY/Jk8mXyZfJm8pINkQ2QDZANjw2PDY4NjQ2NDYwNjA2LDYoNig2JDYkNiA2HDYcNhg1aA2jyaPJp8mnyavJr8mvybPJs8m3ybvJu8m/yb/Jw8nHycfJy8nLyc/IiCoQNgw2DDYINgQ2BDYANgA1/DX4Nfg19DX0NfA17DXsNeg16DXkNtwZ18nXydvJ38nfyePJ48nnyevJ68nvye/J88n3yffJ+8n7yf/KA8oDyugZ3DXYNdQ11DXQNdA1zDXINcg1xDXENcA1vDW8Nbg1uDW0NbA1sDQ4KgvKD8oPyhPKE8oXyhvKG8ofyh/KI8onyifKK8oryi/KM8ozyjfKN8lcDaQ1pDWgNaA1nDWYNZg1lDWUNZA1jDWMNYg1iDWENYA1gDV8NXw1eDY/ykPKQ8pHykvKS8pPyk/KU8pXylfKW8pbyl/KY8pjymfKZ8prym/L8/1wNXA1bDVoNWg1ZDVkNWA1XDVcNVg1WDVUNVA1UDVMNUw1SDVENUQ3z9Z3ynvKe8p/yn/Kg8qHyofKi8qLyo/Kk8qTypfKl8qbyp/Kn8qjyp/xPDU4NTg1NDU0NTA1LDUsNSg1KDUkNSA1IDUcNRw1GDUYNRQ1EDUQNUPmq8qvyq/Ks8q3yrfKu8q7yr/Kw8rDysfKx8rLys/Kz8rTytPK18ln5Qg1BDUENQA0/DT8NPg0+DT0NPA08DTsNOw06DToNOQ04DTgNNw03Daf8t/K48rnyufK68rryu/K88rzyvfK98r7yv/K/8sDywPLB8sLywvIR9jUNNA0zDTMNMg0yDTENMA0wDS8NLw0uDS4NLQ0sDSwNKw0rDSoNKQ33/8XyxfLG8sbyx/LI8sjyyfLJ8sryy/LL8szyzPLN8s7yzvLP8s/y0PInDScNJg0mDSUNJA0kDSMNIw0iDSINIQ0gDSANHw0fDR4NHQ0dDRwNQAPS8tPy0/LU8tTy1fLV8tby1/LX8tjy2PLZ8try2vLb8tvy3PLd8t3y0wkaDRkNGA0YDRcNFw0WDRYNFQ0UDRQNEw0TDRINEQ0RDRANEA0PDYMG3/Lg8uDy4fLi8uLy4/Lj8uTy5PLl8uby5vLn8ufy6PLp8uny6vLq8oUGDA0MDQsNCw0KDQoNCQ0IDQgNBw0HDQYNBQ0FDQQNBA0DDQINAg2/Cezy7fLu8u7y7/Lv8vDy8PLx8vLy8vLz8vPy9PL18vXy9vL28vfy+PI9A/8M/wz+DP4M/Qz8DPwM+wz7DPoM+Qz5DPgM+Az3DPcM9gz1DPUM9Az68vry+/L78vzy/fL98v7y/vL/8v/yAPMB8wHzAvMC8wPzBPME8wXz/P/yDPIM8QzwDPAM7wzvDO4M7QztDOwM7AzrDOsM6gzpDOkM6AzoDOcMQ/YH8wjzCfMJ8wrzCvML8wvzDPMN8w3zDvMO8w/zEPMQ8xHzEfMS88L85QzkDOQM4wzjDOIM4QzhDOAM4AzfDN8M3gzdDN0M3AzcDNsM2gzaDIX5FfMV8xbzFvMX8xjzGPMZ8xnzGvMa8xvzHPMc8x3zHfMe8x/zH/OO+dgM1wzXDNYM1QzVDNQM1AzTDNMM0gzRDNEM0AzQDM8MzgzODM0MzQzB/CLzIvMj8yTzJPMl8yXzJvMn8yfzKPMo8ynzKfMq8yvzK/Ms8yzzYfbLDMoMyQzJDMgMyAzHDMYMxgzFDMUMxAzEDMMMwgzCDMEMwQzADMAM9/8v8zDzMPMx8zHzMvMz8zPzNPM08zXzNfM28zfzN/M48zjzOfM68zrzvQy9DLwMvAy7DLoMugy5DLkMuAy4DLcMtgy2DLUMtQy0DLQMswyyDCYDPPM98z3zPvM/8z/zQPNA80HzQvNC80PzQ/NE80TzRfNG80bzR/NH84MJsAyvDK4MrgytDK0MrAysDKsMqgyqDKkMqQyoDKcMpwymDKYMpQxOBknzSvNL80vzTPNM803zTvNO80/zT/NQ81DzUfNS81LzU/NT81TzVfNQBqIMogyhDKEMoAygDJ8MngyeDJ0MnQycDJsMmwyaDJoMmQyZDJgMbwlX81fzWPNY81nzWvNa81vzW/Nc813zXfNe817zX/Nf82DzYfNh82LzIgOVDJUMlAyUDJMMkgySDJEMkQyQDI8MjwyODI4MjQyNDIwMiwyLDIoMZPNk82XzZvNm82fzZ/No82nzafNq82rza/Nr82zzbfNt827zbvNv8/z/iAyIDIcMhgyGDIUMhQyEDIMMgwyCDIIMgQyBDIAMfwx/DH4Mfgx9DJP2cvNy83Pzc/N083XzdfN283bzd/N483jzefN583rzevN783zzfPPc/HsMegx6DHkMeQx4DHcMdwx2DHYMdQx1DHQMcwxzDHIMcgxxDHEMcAy7+X/zgPOA84HzgfOC84Lzg/OE84TzhfOF84bzhvOH84jziPOJ84nzw/luDG0MbQxsDGsMawxqDGoMaQxpDGgMZwxnDGYMZgxlDGUMZAxjDGMM3PyM843zjfOO847zj/OQ85DzkfOR85Lzk/OT85TzlPOV85XzlvOX87D2YQxgDF8MXwxeDF4MXQxdDFwMWwxbDFoMWgxZDFgMWAxXDFcMVgxWDPf/mfOa85vzm/Oc85zznfOd857zn/Of86DzoPOh86HzovOj86PzpPOk81MMUwxSDFIMUQxQDFAMTwxPDE4MTgxNDEwMTAxLDEsMSgxKDEkMSAwLA6fzp/Oo86jzqfOp86rzq/Or86zzrPOt867zrvOv86/zsPOw87HzsvM0CUYMRQxEDEQMQwxDDEIMQgxBDEAMQAw/DD8MPgw+DD0MPAw8DDsMGQa087TztfO287bzt/O387jzuPO587rzuvO787vzvPO8873zvvO+87/zGwY4DDgMNww3DDYMNgw1DDQMNAwzDDMMMgwyDDEMMAwwDC8MLwwuDCAJwfPC88Lzw/PD88TzxPPF88bzxvPH88fzyPPJ88nzyvPK88vzy/PM8wgDKwwrDCoMKgwpDCgMKAwnDCcMJgwmDCUMJAwkDCMMIwwiDCIMIQwgDM7zz/PP89Dz0fPR89Lz0vPT89Pz1PPV89Xz1vPW89fz1/PY89nz2fP8/x4MHQwdDBwMHAwbDBsMGgwZDBkMGAwYDBcMFwwWDBUMFQwUDBQMEwzi9tzz3fPd897z3vPf89/z4PPh8+Hz4vPi8+Pz4/Pk8+Xz5fPm8+bz9/wRDBAMEAwPDA8MDgwNDA0MDAwMDAsMCwwKDAkMCQwIDAgMBwwHDAYM8Pnp8+rz6vPr8+zz7PPt8+3z7vPu8+/z8PPw8/Hz8fPy8/Lz8/P08/j5BAwDDAMMAgwBDAEMAAwADP8L/wv+C/0L/Qv8C/wL+wv7C/oL+Qv5C/f89/P38/jz+PP58/nz+vP68/vz/PP88/3z/fP+8/7z//MA9AD0AfQA9/YL9gv1C/UL9Av0C/ML8wvyC/EL8QvwC/AL7wvvC+4L7QvtC+wL7Av3/wT0BPQF9AX0BvQH9Af0CPQI9An0CfQK9Av0C/QM9Az0DfQN9A70D/TpC+kL6AvoC+cL5gvmC+UL5QvkC+QL4wvjC+IL4QvhC+AL4AvfC98L8QIR9BL0EvQT9BP0FPQU9BX0FfQW9Bf0F/QY9Bj0GfQZ9Br0G/Qb9Bz05AjcC9sL2gvaC9kL2QvYC9gL1wvWC9YL1QvVC9QL1AvTC9ML0gvRC+QFHvQf9B/0IPQg9CH0IvQi9CP0I/Qk9CT0JfQm9Cb0J/Qn9Cj0KPQp9OYFzgvOC80LzQvMC8wLywvKC8oLyQvJC8gLyAvHC8YLxgvFC8ULxAvRCCv0LPQt9C30LvQu9C/0L/Qw9DD0MfQy9DL0M/Qz9DT0NPQ19Db0NvTuAsELwQvAC78Lvwu+C74LvQu9C7wLvAu7C7oLugu5C7kLuAu4C7cLtws59Dn0OvQ69Dv0O/Q89D30PfQ+9D70P/Q/9ED0QfRB9EL0QvRD9EP0/P+0C7MLswuyC7ILsQuxC7ALsAuvC64LrgutC60LrAusC6sLqguqC6kLMvdG9Ef0SPRI9En0SfRK9Er0S/RL9Ez0TfRN9E70TvRP9E/0UPRR9BH9pwumC6YLpQulC6QLowujC6ILoguhC6ELoAugC58LngueC50LnQucCyX6VPRU9FX0VfRW9Fb0V/RY9Fj0WfRZ9Fr0WvRb9Fz0XPRd9F30XvQt+poLmQuZC5gLlwuXC5YLlguVC5ULlAuTC5MLkguSC5ELkQuQC5ALjwsS/WH0YfRi9GP0Y/Rk9GT0ZfRl9Gb0ZvRn9Gj0aPRp9Gn0avRq9Gv0UPeMC4wLiwuLC4oLiguJC4kLiAuHC4cLhguGC4ULhQuEC4QLgwuCC4IL9/9u9G/0b/Rw9HD0cfRx9HL0c/Rz9HT0dPR19HX0dvR29Hf0ePR49Hn0fwt/C34Lfgt9C3wLfAt7C3sLegt6C3kLeQt4C3cLdwt2C3YLdQt1C9cCe/R89Hz0ffR+9H70f/R/9ID0gPSB9IH0gvSD9IP0hPSE9IX0hfSG9JUIcgtxC3ALcAtvC28LbgtuC20LbQtsC2sLawtqC2oLaQtpC2gLaAuvBYn0ifSK9Ir0i/SL9Iz0jPSN9I70jvSP9I/0kPSQ9JH0kfSS9JP0k/SxBWQLZAtjC2MLYgtiC2ELYAtgC18LXwteC14LXQtdC1wLWwtbC1oLgQiW9Jb0l/SX9Jj0mfSZ9Jr0mvSb9Jv0nPSc9J30nvSe9J/0n/Sg9KD00wJXC1cLVgtVC1ULVAtUC1MLUwtSC1ILUQtRC1ALTwtPC04LTgtNC00Lo/Sk9KT0pfSl9Kb0pvSn9Kf0qPSp9Kn0qvSq9Kv0q/Ss9Kz0rfSu9Pz/SgtJC0kLSAtIC0cLRwtGC0YLRQtEC0QLQwtDC0ILQgtBC0ELQAs/C4L3sfSx9LL0svSz9LT0tPS19LX0tvS29Lf0t/S49Ln0ufS69Lr0u/Qs/T0LPAs8CzsLOws6CzkLOQs4CzgLNws3CzYLNgs1CzULNAszCzMLMgtb+r70v/S/9MD0wPTB9MH0wvTC9MP0xPTE9MX0xfTG9Mb0x/TH9Mj0YvowCy8LLgsuCy0LLQssCywLKwsrCyoLKgspCygLKAsnCycLJgsmCyULLP3L9Mz0zPTN9M30zvTP9M/00PTQ9NH00fTS9NL00/TU9NT01fTV9J/3IgsiCyELIQsgCyALHwsfCx4LHQsdCxwLHAsbCxsLGgsaCxkLGQsYC/j/2PTZ9Nr02vTb9Nv03PTc9N303fTe9N/03/Tg9OD04fTh9OL04vTj9BULFQsUCxQLEwsSCxILEQsRCxALEAsPCw8LDgsOCw0LDAsMCwsLCwu8Aub05vTn9Of06PTo9On06vTq9Ov06/Ts9Oz07fTt9O707vTv9PD08PRFCAcLBwsGCwYLBQsFCwQLBAsDCwMLAgsBCwELAAsAC/8K/wr+Cv4KegXz9PP09PT19PX09vT29Pf09/T49Pj0+fT69Pr0+/T79Pz0/PT99P30fAX6CvoK+Qr5CvgK+Ar3CvYK9gr1CvUK9Ar0CvMK8wryCvIK8QrwCjIIAPUB9QH1AvUC9QP1A/UE9QX1BfUG9Qb1B/UH9Qj1CPUJ9Qn1CvUL9bkC7QrtCuwK6wrrCuoK6grpCukK6AroCucK5wrmCuYK5QrkCuQK4wrjCg31DvUO9Q/1EPUQ9RH1EfUS9RL1E/UT9RT1FPUV9Rb1FvUX9Rf1GPX8/+AK3wrfCt4K3grdCt0K3ArcCtsK2wraCtkK2QrYCtgK1wrXCtYK1grS9xv1HPUc9R31HfUe9R71H/Ug9SD1IfUh9SL1IvUj9SP1JPUk9SX1R/3TCtIK0grRCtEK0ArQCs8KzgrOCs0KzQrMCswKywrLCsoKygrJCsgKkPoo9Sn1KfUq9Sv1K/Us9Sz1LfUt9S71LvUv9S/1MPUx9TH1MvUy9Zj6xgrFCsQKxArDCsMKwgrCCsEKwQrACsAKvwq/Cr4KvQq9CrwKvAq7Ckf9NvU29Tf1N/U49Tj1OfU59Tr1OvU79Tz1PPU99T31PvU+9T/1P/Xv97gKuAq3CrcKtgq2CrUKtQq0CrQKswqyCrIKsQqxCrAKsAqvCq8Krgr4/0P1Q/VE9UT1RfVG9Ub1R/VH9Uj1SPVJ9Un1SvVK9Uv1TPVM9U31TfWrCqsKqgqqCqkKqQqoCqcKpwqmCqYKpQqlCqQKpAqjCqMKogqhCqEKogJQ9VH1UfVS9VL1U/VT9VT1VPVV9VX1VvVX9Vf1WPVY9Vn1WfVa9Vr19gedCp0KnAqcCpsKmwqaCpoKmQqZCpgKmAqXCpYKlgqVCpUKlAqUCkYFXfVe9V71X/Vf9WD1YfVh9WL1YvVj9WP1ZPVk9WX1ZfVm9Wb1Z/Vo9UcFkAqQCo8KjwqOCo4KjQqNCowKiwqLCooKigqJCokKiAqICocKhwrjB2v1a/Vs9Wz1bfVt9W71bvVv9W/1cPVw9XH1cvVy9XP1c/V09XT1dfWeAoMKgwqCCoIKgQqACoAKfwp/Cn4Kfgp9Cn0KfAp8CnsKewp6CnkKeQp49Xj1efV59Xr1evV79Xz1fPV99X31fvV+9X/1f/WA9YD1gfWB9YL1/P92CnUKdQp0CnQKcwpzCnIKcgpxCnEKcApwCm8KbgpuCm0KbQpsCmwKIviG9Yb1h/WH9Yj1iPWJ9Yn1ivWK9Yv1i/WM9Y31jfWO9Y71j/WP9WH9aQpoCmgKZwpnCmYKZgplCmQKZApjCmMKYgpiCmEKYQpgCmAKXwpfCsX6k/WT9ZT1lPWV9ZX1lvWX9Zf1mPWY9Zn1mfWa9Zr1m/Wb9Zz1nPXN+lwKWwpaCloKWQpZClgKWApXClcKVgpWClUKVQpUClQKUwpSClIKUQpi/aD1ofWh9aL1ovWj9aP1pPWk9aX1pfWm9ab1p/Wn9aj1qfWp9ar1P/hOCk4KTQpNCkwKTApLCksKSgpKCkkKSQpICkcKRwpGCkYKRQpFCkQK+P+t9a71rvWv9a/1sPWw9bH1sfWy9bP1s/W09bT1tfW19bb1tvW39bf1QQpBCkAKQAo/Cj8KPgo9Cj0KPAo8CjsKOwo6CjoKOQo5CjgKOAo3CogCuvW79bz1vPW99b31vvW+9b/1v/XA9cD1wfXB9cL1wvXD9cT1xPXF9aYHMwozCjIKMgoxCjEKMAowCi8KLwouCi4KLQotCiwKLAorCioKKgoRBcj1yPXJ9cn1yvXK9cv1y/XM9cz1zfXO9c71z/XP9dD10PXR9dH10vUSBSYKJgolCiUKJAokCiMKIwoiCiIKIQogCiAKHwofCh4KHgodCh0KkwfV9dX11vXW9df12PXY9dn12fXa9dr12/Xb9dz13PXd9d313vXe9d/1hAIZChkKGAoYChcKFgoWChUKFQoUChQKEwoTChIKEgoRChEKEAoQCg8K4vXj9eP15PXk9eX15fXm9eb15/Xn9ej16PXp9er16vXr9ev17PXs9f3/DAoLCgsKCgoKCgkKCQoICggKBwoHCgYKBgoFCgUKBAoDCgMKAgoCCnL48PXw9fH18fXy9fP18/X09fT19fX19fb19vX39ff1+PX49fn1+fV8/f8J/gn+Cf0J/Qn8CfwJ+wn7CfoJ+Qn5CfgJ+An3CfcJ9gn2CfUJ9Qn7+v31/vX+9f/1//UA9gD2AfYB9gL2AvYD9gP2BPYF9gX2BvYG9gf2AvvyCfEJ8AnwCe8J7wnuCe4J7QntCewJ7AnrCesJ6gnqCekJ6QnoCegJff0K9gv2C/YM9gz2DfYO9g72D/YP9hD2EPYR9hH2EvYS9hP2E/YU9o745AnkCeMJ4wniCeIJ4QnhCeAJ4AnfCd8J3gneCd0J3QncCdsJ2wnaCfn/GPYY9hn2GfYa9hr2G/Yb9hz2HPYd9h32HvYe9h/2H/Yg9iH2IfYi9tcJ1wnWCdYJ1QnVCdQJ1AnTCdIJ0gnRCdEJ0AnQCc8JzwnOCc4JzQluAiX2JfYm9ib2J/Yn9ij2KfYp9ir2KvYr9iv2LPYs9i32LfYu9i72L/ZXB8kJyQnICcgJxwnHCcYJxgnFCcUJxAnECcMJwwnCCcIJwQnBCcAJ3AQy9jP2M/Y09jT2NfY19jb2NvY39jf2OPY49jn2OfY69jr2O/Y79jz23QS8CbwJuwm7CboJugm5CbkJuAm4CbcJtwm2CbYJtQm0CbQJswmzCUQHP/ZA9kD2QfZB9kL2QvZD9kP2RPZF9kX2RvZG9kf2R/ZI9kj2SfZJ9mkCrwmvCa4JrgmtCawJrAmrCasJqgmqCakJqQmoCagJpwmnCaYJpgmlCU32TfZO9k72T/ZP9lD2UPZR9lH2UvZS9lP2U/ZU9lT2VfZV9lb2Vvb9/6IJoQmhCaAJoAmfCZ8JngmeCZ0JnQmcCZwJmwmbCZoJmgmZCZkJmAnC+Fr2W/Zb9lz2XPZd9l32XvZe9l/2X/Zg9mH2YfZi9mL2Y/Zj9mT2l/2VCZQJlAmTCZMJkgmSCZEJkQmQCZAJjwmPCY4JjQmNCYwJjAmLCYsJMPto9mj2afZp9mr2avZr9mv2bPZs9m32bfZu9m72b/Zv9nD2cPZx9jf7iAmHCYcJhgmFCYUJhAmECYMJgwmCCYIJgQmBCYAJgAl/CX8Jfgl+CZj9dfZ19nb2dvZ39nf2ePZ49nn2efZ69nr2e/Z79nz2ffZ99n72fvbe+HoJegl5CXkJeAl4CXcJdwl2CXYJdQl1CXQJdAlzCXMJcglyCXEJcQn5/4L2g/aD9oT2hPaF9oX2hvaG9of2h/aI9oj2ifaJ9or2ivaL9ov2jPZtCW0JbAlsCWsJawlqCWoJaQlpCWgJaAlnCWYJZgllCWUJZAlkCWMJUwKP9pD2kPaR9pH2kvaS9pP2k/aU9pT2lfaV9pb2lvaX9pf2mPaZ9pn2BwdfCV8JXgleCV0JXQlcCVwJWwlbCVoJWglZCVkJWAlYCVcJVwlWCacEnPad9p32nvaf9p/2oPag9qH2ofai9qL2o/aj9qT2pPal9qX2pvam9qgEUglSCVEJUQlQCVAJTwlPCU4JTglNCU0JTAlMCUsJSwlKCUoJSQn1Bqr2qvar9qv2rPas9q32rfau9q72r/av9rD2sPax9rH2svay9rP2s/ZPAkUJRQlECUQJQwlDCUIJQglBCUAJQAk/CT8JPgk+CT0JPQk8CTwJOwm39rf2uPa49rn2ufa69rv2u/a89rz2vfa99r72vva/9r/2wPbA9sH2/f84CTcJNwk2CTYJNQk1CTQJNAkzCTMJMgkyCTEJMQkwCTAJLwkvCS4JEfnF9sX2xvbG9sf2x/bI9sj2yfbJ9sr2yvbL9sv2zPbM9s32zfbO9rH9KwkqCSoJKQkpCSgJKAknCScJJgkmCSUJJQkkCSQJIwkjCSIJIgkhCWX70vbS9tP20/bU9tT21fbV9tb21/bX9tj22PbZ9tn22vba9tv22/Zs+x4JHQkdCRwJHAkbCRoJGgkZCRkJGAkYCRcJFwkWCRYJFQkVCRQJFAmy/d/24Pbg9uH24fbi9uL24/bj9uT25Pbl9uX25vbm9uf25/bo9uj2LvkQCRAJDwkPCQ4JDgkNCQ0JDAkMCQsJCwkKCQoJCQkJCQgJCAkHCQcJ+f/s9u327fbu9u727/bv9vD28Pbx9vH28vby9vP28/b09vX29fb29vb2AwkDCQIJAgkBCQEJAAkACf8I/wj+CP4I/Qj9CPwI/Aj7CPsI+gj6CDkC+vb69vv2+/b89vz2/fb99v72/vb/9v/2APcA9wH3AfcC9wL3A/cD97gG9Qj1CPQI9AjzCPMI8gjyCPEI8QjwCPAI7wjvCO4I7gjtCO0I7AhzBAf3B/cI9wj3CfcJ9wr3CvcL9wv3DPcM9w33DfcO9w73D/cP9xD3EPdzBOgI6AjnCOcI5gjmCOUI5QjkCOQI4wjjCOII4gjhCOEI4AjgCN8IpQYU9xX3FfcW9xb3F/cX9xj3GPcZ9xn3Gvca9xv3G/cc9xz3Hfcd9x73NQLbCNsI2gjaCNkI2QjYCNgI1wjXCNYI1gjVCNUI1AjUCNMI0wjSCNIIIfci9yL3I/cj9yT3JPcl9yX3Jvcm9yf3J/co9yj3Kfcp9yr3Kvcr9/3/zgjNCM0IzAjMCMsIywjKCMoIyQjJCMgIyAjHCMcIxgjGCMUIxQjECGH5L/cw9zD3Mfcx9zL3Mvcz9zP3NPc09zX3Nfc29zb3N/c39zj3OPfM/cEIwAjACL8Ivwi+CL4IvQi9CLwIvAi7CLsIugi6CLkIuQi4CLgItwib+zz3Pfc99z73Pvc/9z/3QPdA90H3QfdC90L3Q/dD90T3RPdF90X3ofu0CLMIswiyCLIIsQixCLAIsAivCK8IrgiuCK0IrQisCKwIqwirCKoIzf1K90r3S/dL90z3TPdN9033TvdO90/3T/dQ91D3UfdR91L3UvdT9375pgimCKUIpQikCKQIowijCKIIogihCKEIoAigCJ8InwieCJ4InQidCPn/V/dX91j3WPdZ91n3Wvda91v3W/dc91z3Xfdd9173Xvdf91/3YPdg95kImQiYCJgIlwiXCJYIlgiVCJUIlAiUCJMIkwiSCJIIkQiRCJAIkAgfAmT3Zfdl92b3Zvdn92f3aPdo92n3afdq92r3a/dr92v3bPds9233bfdoBosIiwiKCIoIiQiJCIkIiAiICIcIhwiGCIYIhQiFCIQIhAiDCIMIPgRx93L3cvdz93P3dPd093X3dfd293b3d/d393j3ePd593n3evd693v3PgR+CH4IfQh9CHwIfAh7CHsIegh6CHkIeQh4CHgIdwh3CHYIdgh1CFYGfvd/93/3gPeA94H3gfeC94L3g/eD94T3hPeF94X3hveG94f3h/eI9xoCcQhxCHAIcAhvCG8IbghuCG0IbQhsCGwIawhrCGoIaghpCGkIaAhoCIz3jPeN9433jveO94/3j/eQ95D3kfeR95L3kveT95P3lPeU95X3lff9/2QIYwhjCGIIYghhCGEIYAhgCF8IXwheCF4IXghdCF0IXAhcCFsIWwix+Zn3mvea95v3m/ec95z3nfed9573nvef95/3oPeg96H3ofei96L35v1XCFYIVghVCFUIVAhUCFMIUwhSCFIIUQhRCFAIUAhPCE8ITghOCE0I0Pun96f3qPeo96n3qfeq96r3q/er96z3rPet9633rveu96/3r/ew99f7SghJCEkISAhICEcIRwhGCEYIRQhFCEQIRAhDCEMIQghCCEEIQQhACOj9tPe097X3tfe297b3t/e397j3uPe597n3uve697v3u/e897z3vffN+TwIPAg7CDsIOgg6CDkIOQg4CDgINwg3CDYINgg1CDUINQg0CDQIMwj6/8H3wvfC98P3w/fE98T3xffF98b3xvfH98f3yPfI98n3yffK98r3y/cvCC8ILgguCC0ILQgsCCwIKwgrCCoIKggpCCkIKAgoCCcIJwgmCCYIBQLO98/3z/fQ99D30ffR99L30vfT99P31PfU99X31ffW99b31/fX99j3GQYhCCEIIQggCCAIHwgfCB4IHggdCB0IHAgcCBsIGwgaCBoIGQgZCAkE3Pfc99333ffe99733/ff9+D34Pfh9+H34vfi9+P34/fj9+T35Pfl9wkEFAgUCBMIEwgSCBIIEQgRCBAIEAgPCA8IDggOCA0IDQgNCAwIDAgHBun36ffq9+r36/fr9+z37Pft9+337vfu9+/37/fw9/D38ffx9/L38vcAAgcIBwgGCAYIBQgFCAQIBAgDCAMIAggCCAEIAQgACAAI/wf/B/4H/gf29/f39/f49/j3+ff59/r3+vf79/v3+/f89/z3/ff99/73/vf/9//3/f/6B/kH+Qf4B/gH+Af3B/cH9gf2B/UH9Qf0B/QH8wfzB/IH8gfxB/EHAfoE+AT4BfgF+Ab4BvgH+Af4CPgI+An4CfgK+Ar4C/gL+Az4DPgN+AH+7QfsB+wH6wfrB+oH6gfpB+kH6AfoB+cH5wfmB+YH5QflB+UH5AfkBwX8EfgS+BL4E/gT+BT4FPgV+BX4FfgW+Bb4F/gX+Bj4GPgZ+Bn4GvgM/OAH3wffB94H3gfdB90H3AfcB9sH2wfaB9oH2QfZB9gH2AfXB9cH1gcD/h74H/gf+CD4IPgh+CH4Ivgi+CP4I/gk+CT4Jfgl+Cb4Jvgn+Cf4HfrSB9IH0QfRB9AH0AfPB88HzwfOB84HzQfNB8wHzAfLB8sHygfKB8kH+v8s+Cz4Lfgt+C74Lvgu+C/4L/gw+DD4Mfgx+DL4Mvgz+DP4NPg0+DX4xQfFB8QHxAfDB8MHwgfCB8EHwQfAB8AHvwe/B74Hvge9B70HvQe8B+oBOfg5+Dr4Ovg7+Dv4PPg8+D34Pfg++D74P/g/+ED4QPhB+EH4QvhC+MkFuAe3B7cHtge2B7UHtQe0B7QHswezB7IHsgexB7EHsAewB68HrwfUA0b4R/hH+Ej4SPhI+En4SfhK+Er4S/hL+Ez4TPhN+E34TvhO+E/4T/jUA6oHqgepB6kHqAeoB6cHpwemB6YHpgelB6UHpAekB6MHoweiB6IHtwVT+FT4VPhV+FX4VvhW+Ff4V/hY+Fj4WfhZ+Fr4Wvhb+Fv4W/hc+Fz45QGdB50HnAecB5sHmweaB5oHmQeZB5gHmAeXB5cHlgeWB5UHlQeVB5QHYfhh+GL4Yvhi+GP4Y/hk+GT4Zfhl+Gb4Zvhn+Gf4aPho+Gn4afhq+P3/kAePB48HjweOB44HjQeNB4wHjAeLB4sHigeKB4kHiQeIB4gHhweHB1H6bvhv+G/4cPhw+HH4cfhy+HL4c/hz+HT4dPh0+HX4dfh2+Hb4d/gc/oMHggeCB4EHgQeAB4AHfwd/B34Hfgd+B30HfQd8B3wHewd7B3oHegc7/Hz4fPh8+H34ffh++H74f/h/+ID4gPiB+IH4gviC+IP4g/iE+IT4Qfx2B3UHdQd0B3QHcwdzB3IHcgdxB3EHcAdwB28HbwduB24HbgdtB20HHv6J+In4iviK+Iv4i/iM+Iz4jfiN+I74jviO+I/4j/iQ+JD4kfiR+G36aAdoB2cHZwdmB2YHZgdlB2UHZAdkB2MHYwdiB2IHYQdhB2AHYAdfB/r/lviW+Jf4l/iY+Jj4mfiZ+Jr4mvib+Jv4nPic+J34nfie+J74n/if+FsHWwdaB1oHWQdZB1gHWAdXB1cHVgdWB1YHVQdVB1QHVAdTB1MHUgfQAaP4pPik+KX4pfim+Kb4p/in+Kf4qPio+Kn4qfiq+Kr4q/ir+Kz4rPh6BU4HTQdNB0wHTAdLB0sHSgdKB0kHSQdIB0gHRwdHB0YHRgdGB0UHnwOw+LH4sfiy+LL4s/iz+LT4tPi1+LX4tvi2+Lf4t/i4+Lj4ufi5+Ln4nwNAB0AHPwc/Bz4HPgc+Bz0HPQc8BzwHOwc7BzoHOgc5BzkHOAc4B2gFvvi++L/4v/jA+MD4wfjB+MH4wvjC+MP4w/jE+MT4xfjF+Mb4xvjH+MsBMwczBzIHMgcxBzEHMAcwBy8HLwcuBy4HLgctBy0HLAcsBysHKwcqB8v4y/jM+Mz4zfjN+M74zvjP+M/40PjQ+NH40fjS+NL40vjT+NP41Pj9/yYHJQclByUHJAckByMHIwciByIHIQchByAHIAcfBx8HHgceBx4HHQeh+tn42fja+Nr42/jb+Nv43Pjc+N343fje+N743/jf+OD44Pjh+OH4Nv4ZBxgHGAcXBxcHFgcWBxYHFQcVBxQHFAcTBxMHEgcSBxEHEQcQBxAHcPzm+Ob45/jn+Oj46Pjp+On46vjq+Ov46/js+Oz47Pjt+O347vju+Hb8DAcLBwsHCgcKBwkHCQcIBwgHBwcHBwYHBgcGBwUHBQcEBwQHAwcDBzn+8/j0+PT49fj1+PX49vj2+Pf49/j4+Pj4+fj5+Pr4+vj7+Pv4/Pi8+v4G/gb9Bv0G/Qb8BvwG+wb7BvoG+gb5BvkG+Ab4BvcG9wb3BvYG9gb7/wD5AfkB+QL5AvkD+QP5BPkE+QX5BfkG+Qb5BvkH+Qf5CPkI+Qn5CfnxBvEG8AbwBu8G7wbuBu4G7QbtBu0G7AbsBusG6wbqBuoG6QbpBugGtgEO+Q75D/kP+RD5EPkQ+RH5EfkS+RL5E/kT+RT5FPkV+RX5FvkW+Rb5KgXkBuMG4wbiBuIG4QbhBuAG4AbfBt8G3gbeBt4G3QbdBtwG3AbbBmsDG/kb+Rz5HPkd+R35Hvke+R/5H/kg+SD5IPkh+SH5Ivki+SP5I/kk+WoD1gbWBtUG1QbVBtQG1AbTBtMG0gbSBtEG0QbQBtAGzwbPBs8GzgYZBSj5Kfkp+Sr5Kvkq+Sv5K/ks+Sz5Lfkt+S75Lvkv+S/5MPkw+TD5MfmxAckGyQbIBsgGxwbHBsYGxgbFBsUGxQbEBsQGwwbDBsIGwgbBBsEGwAY1+Tb5Nvk3+Tf5OPk4+Tn5Ofk6+Tr5Ovk7+Tv5PPk8+T35Pfk++T75/f+8BrwGuwa7BroGuga5BrkGuAa4BrcGtwa2BrYGtga1BrUGtAa0BrMG8fpD+UT5RPlE+UX5RflG+Ub5R/lH+Uj5SPlJ+Un5SvlK+Ur5S/lL+VH+rwauBq4GrQatBq0GrAasBqsGqwaqBqoGqQapBqgGqAanBqcGpwamBqX8UPlR+VH5UvlS+VP5U/lU+VT5VPlV+VX5VvlW+Vf5V/lY+Vj5Wfmr/KIGoQahBqAGoAafBp8GngaeBp0GnQadBpwGnAabBpsGmgaaBpkGmQZT/l75Xvle+V/5X/lg+WD5Yflh+WL5Yvlj+WP5ZPlk+WT5Zfll+Wb5DPuUBpQGlAaTBpMGkgaSBpEGkQaQBpAGjwaPBo4GjgaOBo0GjQaMBowG+/9r+Wv5bPls+W35bflu+W75bvlv+W/5cPlw+XH5cfly+XL5c/lz+XT5hwaHBoYGhgaFBoUGhAaEBoQGgwaDBoIGggaBBoEGgAaABn8GfwZ/BpwBePl5+Xn5efl6+Xr5e/l7+Xz5fPl9+X35fvl++X75f/l/+YD5gPmB+dsEegZ5BnkGeAZ4BncGdwZ2BnYGdQZ1BnUGdAZ0BnMGcwZyBnIGcQY2A4X5hvmG+Yf5h/mI+Yj5ifmJ+Yn5ivmK+Yv5i/mM+Yz5jfmN+Y75jvk1A2wGbAZrBmsGawZqBmoGaQZpBmgGaAZnBmcGZwZmBmYGZQZlBmQGygST+ZP5k/mU+ZT5lfmV+Zb5lvmX+Zf5mPmY+Zj5mfmZ+Zr5mvmb+Zv5lgFfBl8GXgZeBl0GXQZcBlwGXAZbBlsGWgZaBlkGWQZYBlgGWAZXBlcGoPmg+aH5ofmi+aL5o/mj+aP5pPmk+aX5pfmm+ab5p/mn+aj5qPmo+f7/UgZSBlEGUQZQBlAGTwZPBk4GTgZOBk0GTQZMBkwGSwZLBkoGSgZJBkH7rvmu+a75r/mv+bD5sPmx+bH5svmy+bL5s/mz+bT5tPm1+bX5tvls/kUGRAZEBkMGQwZDBkIGQgZBBkEGQAZABj8GPwY/Bj4GPgY9Bj0GPAbb/Lv5u/m8+bz5vfm9+b35vvm++b/5v/nA+cD5wfnB+cL5wvnC+cP54Pw4BjcGNwY2BjYGNQY1BjQGNAY0BjMGMwYyBjIGMQYxBjAGMAYwBi8Gbv7I+cj5yfnJ+cr5yvnL+cv5zPnM+cz5zfnN+c75zvnP+c/50PnQ+Vz7KgYqBioGKQYpBigGKAYnBicGJgYmBiYGJQYlBiQGJAYjBiMGIgYiBvv/1fnW+db51/nX+df52PnY+dn52fna+dr52/nb+dz53Pnc+d353fne+R0GHQYcBhwGGwYbBhsGGgYaBhkGGQYYBhgGFwYXBhcGFgYWBhUGFQaBAeL54/nj+eT55Pnl+eX55vnm+ef55/nn+ej56Pnp+en56vnq+ev56/mLBBAGDwYPBg4GDgYNBg0GDAYMBgwGCwYLBgoGCgYJBgkGCAYIBggGAQPw+fD58fnx+fL58vny+fP58/n0+fT59fn1+fb59vn2+ff59/n4+fj5AAMCBgIGAgYBBgEGAAYABv8F/wX+Bf4F/gX9Bf0F/AX8BfsF+wX6BXoE/fn9+f75/vn/+f/5APoA+gH6AfoB+gL6AvoD+gP6BPoE+gX6BfoF+nwB9QX1BfQF9AXzBfMF8wXyBfIF8QXxBfAF8AXvBe8F7wXuBe4F7QXtBQr6C/oL+gz6DPoM+g36DfoO+g76D/oP+hD6EPoQ+hH6EfoS+hL6E/r+/+gF6AXnBecF5gXmBeUF5QXlBeQF5AXjBeMF4gXiBeEF4QXhBeAF4AWQ+xj6GPoZ+hn6Gvoa+hv6G/ob+hz6HPod+h36Hvoe+h/6H/of+iD6hv7bBdoF2gXaBdkF2QXYBdgF1wXXBdYF1gXWBdUF1QXUBdQF0wXTBdIFEP0l+ib6Jvom+if6J/oo+ij6Kfop+ir6Kvoq+iv6K/os+iz6Lfot+hb9zgXNBc0FzAXMBcsFywXLBcoFygXJBckFyAXIBccFxwXHBcYFxgXFBYn+Mvoz+jP6NPo0+jX6Nfo2+jb6Nvo3+jf6OPo4+jn6Ofo5+jr6Ovqs+8AFwAXABb8FvwW+Bb4FvQW9Bb0FvAW8BbsFuwW6BboFuQW5BbkFuAX7/0D6QPpB+kH6QfpC+kL6Q/pD+kT6RPpF+kX6RfpG+kb6R/pH+kj6SPqzBbMFsgWyBbIFsQWxBbAFsAWvBa8FrgWuBa4FrQWtBawFrAWrBasFZwFN+k36TvpO+k/6T/pQ+lD6UPpR+lH6UvpS+lP6U/pU+lT6VPpV+lX6PASmBaUFpQWkBaQFowWjBaMFogWiBaEFoQWgBaAFoAWfBZ8FngWeBcwCWvpb+lv6W/pc+lz6Xfpd+l76Xvpf+l/6X/pg+mD6Yfph+mL6Yvpi+ssCmAWYBZgFlwWXBZYFlgWVBZUFlQWUBZQFkwWTBZIFkgWRBZEFkQUrBGf6aPpo+mn6afpq+mr6avpr+mv6bPps+m36bfpu+m76bvpv+m/6cPphAYsFiwWKBYoFigWJBYkFiAWIBYcFhwWGBYYFhgWFBYUFhAWEBYMFgwV1+nX6dvp2+nb6d/p3+nj6ePp5+nn6efp6+nr6e/p7+nz6fPp9+n36/v9+BX4FfQV9BXwFfAV7BXsFewV6BXoFeQV5BXgFeAV4BXcFdwV2BXYF4PuC+oP6g/qE+oT6hfqF+oX6hvqG+of6h/qI+oj6iPqJ+on6ivqK+qH+cQVwBXAFcAVvBW8FbgVuBW0FbQVtBWwFbAVrBWsFagVqBWoFaQVpBUX9kPqQ+pD6kfqR+pL6kvqT+pP6lPqU+pT6lfqV+pb6lvqX+pf6l/pL/WQFYwVjBWIFYgViBWEFYQVgBWAFXwVfBV8FXgVeBV0FXQVcBVwFWwWk/p36nfqe+p76n/qf+p/6oPqg+qH6ofqi+qL6ovqj+qP6pPqk+qX6+/tWBVYFVgVVBVUFVAVUBVMFUwVTBVIFUgVRBVEFUAVQBVAFTwVPBU4F/P+q+qv6q/qr+qz6rPqt+q36rvqu+q76r/qv+rD6sPqx+rH6sfqy+rL6SQVJBUgFSAVIBUcFRwVGBUYFRQVFBUUFRAVEBUMFQwVCBUIFQgVBBU0Bt/q4+rj6ufq5+rn6uvq6+rv6u/q8+rz6vfq9+r36vvq++r/6v/rA+u0DPAU7BTsFOgU6BToFOQU5BTgFOAU3BTcFNwU2BTYFNQU1BTQFNAWYAsX6xfrF+sb6xvrH+sf6yPrI+sj6yfrJ+sr6yvrL+sv6y/rM+sz6zfqWAi4FLgUuBS0FLQUsBSwFKwUrBSsFKgUqBSkFKQUpBSgFKAUnBScF3APS+tL60/rT+tT61PrU+tX61frW+tb61/rX+tf62PrY+tn62fra+tr6RwEhBSEFIAUgBSAFHwUfBR4FHgUdBR0FHQUcBRwFGwUbBRoFGgUaBRkF3/rg+uD64Prh+uH64vri+uP64/rj+uT65Prl+uX65vrm+ub65/rn+v7/FAUUBRMFEwUSBRIFEgURBREFEAUQBQ8FDwUPBQ4FDgUNBQ0FDAUMBTD87frt+u767vru+u/67/rw+vD68frx+vH68vry+vP68/r0+vT69Pq8/gcFBgUGBQYFBQUFBQQFBAUEBQMFAwUCBQIFAQUBBQEFAAUABf8E/wR7/fr6+vr7+vv6/Pr8+v36/fr9+v76/vr/+v/6APsA+wD7AfsB+wL7gP36BPkE+QT4BPgE+AT3BPcE9gT2BPUE9QT1BPQE9ATzBPME8wTyBPIEv/4H+wj7CPsJ+wn7CfsK+wr7C/sL+wz7DPsM+w37DfsO+w77DvsP+0v87QTsBOwE6wTrBOoE6gTqBOkE6QToBOgE5wTnBOcE5gTmBOUE5QTlBPz/FfsV+xX7FvsW+xf7F/sX+xj7GPsZ+xn7Gvsa+xr7G/sb+xz7HPsd+98E3wTeBN4E3gTdBN0E3ATcBNwE2wTbBNoE2gTZBNkE2QTYBNgE1wQzASL7Ivsj+yP7I/sk+yT7Jfsl+yb7Jvsm+yf7J/so+yj7Kfsp+yn7KvudA9IE0QTRBNAE0ATQBM8EzwTOBM4EzgTNBM0EzATMBMsEywTLBMoEYwIv+y/7MPsw+zH7Mfsy+zL7Mvsz+zP7NPs0+zX7Nfs1+zb7Nvs3+zf7YgLFBMQExATDBMMEwgTCBMIEwQTBBMAEwAS/BL8EvwS+BL4EvQS9BIwDPPs9+z37Pvs++z77P/s/+0D7QPtA+0H7QftC+0L7Q/tD+0P7RPtE+y0BtwS3BLYEtgS2BLUEtQS0BLQEtASzBLMEsgSyBLEEsQSxBLAEsASvBEr7SvtK+0v7S/tM+0z7TPtN+037TvtO+0/7T/tP+1D7UPtR+1H7Ufv+/6oEqgSpBKkEqASoBKgEpwSnBKYEpgSmBKUEpQSkBKQEowSjBKMEogSA/Ff7WPtY+1j7WftZ+1r7Wvtb+1v7W/tc+1z7Xftd+137Xvte+1/71v6dBJ0EnAScBJsEmwSaBJoEmgSZBJkEmASYBJgElwSXBJYElgSVBJUEsP1k+2X7Zftm+2b7Z/tn+2f7aPto+2n7aftp+2r7avtr+2v7bPts+7X9kASPBI8EjwSOBI4EjQSNBIwEjASMBIsEiwSKBIoEigSJBIkEiASIBNr+cvty+3P7c/tz+3T7dPt1+3X7dft2+3b7d/t3+3j7ePt4+3n7efub/IMEggSCBIEEgQSABIAEgAR/BH8EfgR+BH4EfQR9BHwEfAR8BHsEewT8/3/7f/uA+4D7gfuB+4H7gvuC+4P7g/uE+4T7hPuF+4X7hvuG+4b7h/t1BHUEdQR0BHQEcwRzBHIEcgRyBHEEcQRwBHAEcARvBG8EbgRuBG4EGQGM+437jfuN+477jvuP+4/7kPuQ+5D7kfuR+5L7kvuS+5P7k/uU+5T7TgNoBGcEZwRnBGYEZgRlBGUEZARkBGQEYwRjBGIEYgRiBGEEYQRgBC4Cmfua+5r7m/ub+5z7nPuc+537nfue+577nvuf+5/7oPug+6D7ofuh+y0CWwRaBFoEWQRZBFkEWARYBFcEVwRWBFYEVgRVBFUEVARUBFQEUwQ9A6f7p/uo+6j7qPup+6n7qvuq+6r7q/ur+6z7rPus+637rfuu+677r/sSAU0ETQRNBEwETARLBEsESwRKBEoESQRJBEgESARIBEcERwRGBEYERgS0+7T7tfu1+7b7tvu2+7f7t/u4+7j7uPu5+7n7uvu6+7v7u/u7+7z7/v9ABEAEPwQ/BD8EPgQ+BD0EPQQ9BDwEPAQ7BDsEOgQ6BDoEOQQ5BDgE0PzC+8L7wvvD+8P7xPvE+8T7xfvF+8b7xvvH+8f7x/vI+8j7yfvJ+/H+MwQzBDIEMgQxBDEEMQQwBDAELwQvBC8ELgQuBC0ELQQsBCwELAQrBOb9z/vP+9D70PvR+9H70fvS+9L70/vT+9P71PvU+9X71fvV+9b71vvr/SYEJQQlBCUEJAQkBCMEIwQjBCIEIgQhBCEEIQQgBCAEHwQfBB4EHgT0/tz73fvd+9373vve+9/73/vf++D74Pvh++H74fvi++L74/vj++P76/wZBBgEGAQXBBcEFwQWBBYEFQQVBBUEFAQUBBMEEwQTBBIEEgQRBBEE/f/p++r76vvr++v76/vs++z77fvt++377vvu++/77/vv+/D78Pvx+/H7CwQLBAsECgQKBAkECQQJBAgECAQHBAcEBwQGBAYEBQQFBAUEBAQEBP4A9/v3+/f7+Pv4+/n7+fv5+/r7+vv7+/v7/Pv8+/z7/fv9+/77/vv++/4C/gP9A/0D/QP8A/wD+wP7A/sD+gP6A/kD+QP5A/gD+AP3A/cD9wP5AQT8BPwF/AX8BvwG/Ab8B/wH/Aj8CPwI/An8CfwK/Ar8CvwL/Av8DPz4AfED8APwA+8D7wPvA+4D7gPtA+0D7QPsA+wD6wPrA+sD6gPqA+kD7gIR/BL8EvwS/BP8E/wU/BT8FPwV/BX8FvwW/Bb8F/wX/Bj8GPwY/Bn8+ADjA+MD4wPiA+ID4QPhA+ED4APgA98D3wPfA94D3gPdA90D3QPcA9wDHvwf/B/8IPwg/CD8Ifwh/CL8Ivwi/CP8I/wk/CT8JPwl/CX8Jvwm/P//1gPWA9UD1QPVA9QD1APTA9MD0wPSA9ID0QPRA9ED0APQA88DzwPPAyD9LPws/C38Lfwu/C78Lvwv/C/8MPww/DD8Mfwx/DL8Mvwy/DP8M/wM/8kDyQPIA8gDxwPHA8cDxgPGA8UDxQPFA8QDxAPDA8MDwwPCA8IDwQMb/jn8Ovw6/Dv8O/w7/Dz8PPw9/D38Pfw+/D78P/w//D/8QPxA/EH8IP68A7sDuwO7A7oDugO5A7kDuQO4A7gDtwO3A7cDtgO2A7UDtQO1A7QDD/9H/Ef8R/xI/Ej8SfxJ/En8SvxK/Ev8S/xL/Ez8TPxN/E38TfxO/Dr9rwOuA64DrQOtA60DrAOsA6sDqwOrA6oDqgOpA6kDqQOoA6gDpwOnA/3/VPxU/FX8VfxV/Fb8VvxX/Ff8V/xY/Fj8WfxZ/Fn8Wvxa/Fv8W/xb/KEDoQOhA6ADoAOfA58DnwOeA54DnQOdA50DnAOcA5wDmwObA5oDmgPkAGH8Yfxi/GL8Y/xj/GP8ZPxk/GX8Zfxl/Gb8Zvxn/Gf8Z/xo/Gj8afyvApQDkwOTA5MDkgOSA5EDkQORA5ADkAOQA48DjwOOA44DjgONA40DxQFu/G/8b/xw/HD8cPxx/HH8cfxy/HL8c/xz/HP8dPx0/HX8dfx1/Hb8wwGHA4YDhgOFA4UDhQOEA4QDhAODA4MDggOCA4IDgQOBA4ADgAOAA58CfPx8/Hz8ffx9/H78fvx+/H/8f/yA/ID8gPyB/IH8gvyC/IL8g/yD/N0AeQN5A3kDeAN4A3cDdwN3A3YDdgN2A3UDdQN0A3QDdANzA3MDcgNyA4n8ifyK/Ir8ivyL/Iv8jPyM/Iz8jfyN/I78jvyO/I/8j/yQ/JD8kPz//2wDbANrA2sDawNqA2oDagNpA2kDaANoA2gDZwNnA2YDZgNmA2UDZQNw/Zb8l/yX/Jj8mPyY/Jn8mfya/Jr8mvyb/Jv8nPyc/Jz8nfyd/J78Jv9fA18DXgNeA14DXQNdA1wDXANcA1sDWwNaA1oDWgNZA1kDWANYA1gDUP6k/KT8pfyl/KX8pvym/Kb8p/yn/Kj8qPyo/Kn8qfyq/Kr8qvyr/FX+UgNRA1EDUQNQA1ADUANPA08DTgNOA04DTQNNA0wDTANMA0sDSwNKAyr/sfyx/LL8svyz/LP8s/y0/LT8tfy1/LX8tvy2/Lb8t/y3/Lj8uPyK/UUDRANEA0QDQwNDA0IDQgNCA0EDQQNAA0ADQAM/Az8DPgM+Az4DPQP9/778v/y//L/8wPzA/MH8wfzB/ML8wvzD/MP8w/zE/MT8xPzF/MX8xvw3AzcDNwM2AzYDNgM1AzUDNAM0AzQDMwMzAzIDMgMyAzEDMQMxAzADygDM/Mz8zPzN/M38zfzO/M78z/zP/M/80PzQ/NH80fzR/NL80vzT/NP8XwIqAyoDKQMpAygDKAMoAycDJwMmAyYDJgMlAyUDJAMkAyQDIwMjA5AB2fzZ/Nr82vza/Nv82/zb/Nz83Pzd/N383fze/N783/zf/N/84Pzg/I4BHQMcAxwDHAMbAxsDGgMaAxoDGQMZAxgDGAMYAxcDFwMXAxYDFgNPAub85vzn/Of86Pzo/Oj86fzp/On86vzq/Ov86/zr/Oz87Pzt/O387fzDABADDwMPAw4DDgMOAw0DDQMMAwwDDAMLAwsDCwMKAwoDCQMJAwkDCAPz/PT89Pz0/PX89fz2/Pb89vz3/Pf8+Pz4/Pj8+fz5/Pn8+vz6/Pv8//8CAwIDAgMBAwEDAAMAAwAD/wL/Av4C/gL+Av0C/QL9AvwC/AL7AvsCwP0B/QH9Av0C/QL9A/0D/QT9BP0E/QX9Bf0G/Qb9Bv0H/Qf9B/0I/UH/9QL1AvQC9AL0AvMC8wLyAvIC8gLxAvEC8QLwAvAC7wLvAu8C7gLuAob+Dv0P/Q/9D/0Q/RD9EP0R/RH9Ev0S/RL9E/0T/RT9FP0U/RX9Ff2K/ugC6ALnAucC5gLmAuYC5QLlAuQC5ALkAuMC4wLjAuIC4gLhAuEC4QJF/xv9HP0c/R39Hf0d/R79Hv0e/R/9H/0g/SD9IP0h/SH9Iv0i/SL92v3bAtoC2gLaAtkC2QLYAtgC2ALXAtcC1wLWAtYC1QLVAtUC1ALUAtQC/v8p/Sn9Kf0q/Sr9K/0r/Sv9LP0s/S39Lf0t/S79Lv0u/S/9L/0w/TD9zgLNAs0CzALMAswCywLLAsoCygLKAskCyQLJAsgCyALHAscCxwLGArAANv02/Tf9N/03/Tj9OP05/Tn9Of06/Tr9O/07/Tv9PP08/Tz9Pf09/RACwALAAr8CvwK+Ar4CvgK9Ar0CvQK8ArwCuwK7ArsCugK6AroCuQJbAUP9RP1E/UT9Rf1F/UX9Rv1G/Uf9R/1H/Uj9SP1J/Un9Sf1K/Ur9Sv1ZAbMCsgKyArICsQKxArACsAKwAq8CrwKvAq4CrgKtAq0CrQKsAqwCAAJQ/VH9Uf1S/VL9Uv1T/VP9U/1U/VT9Vf1V/VX9Vv1W/Vf9V/1X/Vj9qQCmAqUCpQKkAqQCpAKjAqMCowKiAqICoQKhAqECoAKgAqACnwKfAp4CXv1e/V79X/1f/WD9YP1g/WH9Yf1h/WL9Yv1j/WP9Y/1k/WT9Zf1l/f//mAKYApgClwKXApYClgKWApUClQKVApQClAKTApMCkwKSApICkgKRAhD+a/1s/Wz9bP1t/W39bv1u/W79b/1v/W/9cP1w/XH9cf1x/XL9cv1c/4sCiwKKAooCigKJAokCiQKIAogChwKHAocChgKGAoYChQKFAoQChAK7/nn9ef15/Xr9ev16/Xv9e/18/Xz9fP19/X39ff1+/X79f/1//X/9v/5+An4CfQJ9AnwCfAJ8AnsCewJ7AnoCegJ5AnkCeQJ4AngCeAJ3AncCYP+G/Yb9h/2H/Yf9iP2I/Yj9if2J/Yr9iv2K/Yv9i/2L/Yz9jP2N/Sn+cQJwAnACcAJvAm8CbwJuAm4CbQJtAm0CbAJsAmwCawJrAmoCagJqAv7/k/2U/ZT9lP2V/ZX9lf2W/Zb9lv2X/Zf9mP2Y/Zj9mf2Z/Zn9mv2a/WQCYwJjAmICYgJiAmECYQJhAmACYAJfAl8CXwJeAl4CXgJdAl0CXQKWAKD9of2h/aL9ov2i/aP9o/2j/aT9pP2k/aX9pf2m/ab9pv2n/af9p/3BAVYCVgJVAlUCVQJUAlQCUwJTAlMCUgJSAlICUQJRAlACUAJQAk8CJwGu/a79rv2v/a/9r/2w/bD9sf2x/bH9sv2y/bL9s/2z/bT9tP20/bX9JAFJAkgCSAJIAkcCRwJHAkYCRgJFAkUCRQJEAkQCRAJDAkMCQwJCArEBu/27/bz9vP28/b39vf29/b79vv2//b/9v/3A/cD9wP3B/cH9wv3C/Y4APAI7AjsCOgI6AjoCOQI5AjkCOAI4AjgCNwI3AjYCNgI2AjUCNQI1Asj9yf3J/cn9yv3K/cr9y/3L/cv9zP3M/c39zf3N/c79zv3O/c/9z/3//y4CLgIuAi0CLQItAiwCLAIrAisCKwIqAioCKgIpAikCKQIoAigCJwJg/tb91v3X/df91/3Y/dj92P3Z/dn92f3a/dr92/3b/dv93P3c/dz9d/8hAiECIAIgAiACHwIfAh8CHgIeAh4CHQIdAhwCHAIcAhsCGwIbAhoC8P7j/eP95P3k/eX95f3l/eb95v3m/ef95/3n/ej96P3p/en96f3q/fX+FAIUAhMCEwITAhICEgIRAhECEQIQAhACEAIPAg8CDwIOAg4CDQINAnv/8P3x/fH98f3y/fL98/3z/fP99P30/fT99f31/fX99v32/ff99/15/gcCBgIGAgYCBQIFAgUCBAIEAgQCAwIDAgICAgICAgECAQIBAgACAAL+//79/v3+/f/9//3//QD+AP4A/gH+Af4C/gL+Av4D/gP+A/4E/gT+BP76AfkB+QH5AfgB+AH3AfcB9wH2AfYB9gH1AfUB9QH0AfQB8wHzAfMBewAL/gv+DP4M/gz+Df4N/g3+Dv4O/g7+D/4P/hD+EP4Q/hH+Ef4R/hL+cQHsAewB6wHrAesB6gHqAeoB6QHpAegB6AHoAecB5wHnAeYB5gHmAfIAGP4Y/hn+Gf4a/hr+Gv4b/hv+G/4c/hz+HP4d/h3+Hv4e/h7+H/4f/u8A3wHeAd4B3gHdAd0B3QHcAdwB3AHbAdsB2wHaAdoB2QHZAdkB2AFiASX+Jv4m/ib+J/4n/ij+KP4o/in+Kf4p/ir+Kv4q/iv+K/4r/iz+LP50ANIB0QHRAdEB0AHQAc8BzwHPAc4BzgHOAc0BzQHNAcwBzAHMAcsBywEz/jP+M/40/jT+NP41/jX+Nf42/jb+N/43/jf+OP44/jj+Of45/jn+///EAcQBxAHDAcMBwwHCAcIBwgHBAcEBwQHAAcABvwG/Ab8BvgG+Ab4BsP5A/kH+Qf5B/kL+Qv5C/kP+Q/5D/kT+RP5F/kX+Rf5G/kb+Rv5H/pH/twG3AbcBtgG2AbUBtQG1AbQBtAG0AbMBswGzAbIBsgGyAbEBsQGwASb/Tf5O/k7+T/5P/k/+UP5Q/lD+Uf5R/lH+Uv5S/lP+U/5T/lT+VP4q/6oBqgGpAakBqQGoAagBqAGnAacBpgGmAaYBpQGlAaUBpAGkAaQBowGW/1v+W/5b/lz+XP5d/l3+Xf5e/l7+Xv5f/l/+X/5g/mD+YP5h/mH+yf6dAZwBnAGcAZsBmwGbAZoBmgGaAZkBmQGZAZgBmAGYAZcBlwGWAZYB//9o/mj+af5p/mn+av5q/mv+a/5r/mz+bP5s/m3+bf5t/m7+bv5u/m/+kAGPAY8BjwGOAY4BjgGNAY0BjAGMAYwBiwGLAYsBigGKAYoBiQGJAWEAdf52/nb+dv53/nf+d/54/nj+eP55/nn+ev56/nr+e/57/nv+fP58/iIBggGCAYEBgQGBAYABgAGAAX8BfwF/AX4BfgF+AX0BfQF8AXwBfAG9AIP+g/6D/oT+hP6E/oX+hf6F/ob+hv6G/of+h/6H/oj+iP6J/on+if66AHUBdQF0AXQBcwFzAXMBcgFyAXIBcQFxAXEBcAFwAXABbwFvAW8BEgGQ/pD+kf6R/pH+kv6S/pL+k/6T/pP+lP6U/pT+lf6V/pX+lv6W/pb+WgBoAWcBZwFnAWYBZgFmAWUBZQFlAWQBZAFjAWMBYwFiAWIBYgFhAWEBnf6d/p7+nv6e/p/+n/6g/qD+oP6h/qH+of6i/qL+ov6j/qP+o/6k/v//WwFaAVoBWQFZAVkBWAFYAVgBVwFXAVcBVgFWAVYBVQFVAVUBVAFUAf/+q/6r/qv+rP6s/qz+rf6t/q3+rv6u/q/+r/6v/rD+sP6w/rH+sf6s/00BTQFNAUwBTAFMAUsBSwFLAUoBSgFJAUkBSQFIAUgBSAFHAUcBRwFb/7j+uP65/rn+uf66/rr+uv67/rv+u/68/rz+vP69/r3+vv6+/r7+X/9AAUABPwE/AT8BPgE+AT4BPQE9AT0BPAE8ATwBOwE7ATsBOgE6AToBsP/F/sb+xv7G/sf+x/7H/sj+yP7I/sn+yf7J/sr+yv7K/sv+y/7L/hn/MwEzATIBMgEyATEBMQEwATABMAEvAS8BLwEuAS4BLgEtAS0BLQEsAf//0v7T/tP+0/7U/tT+1f7V/tX+1v7W/tb+1/7X/tf+2P7Y/tj+2f7Z/iYBJQElASUBJAEkASQBIwEjASMBIgEiASIBIQEhASEBIAEgAR8BHwFHAOD+4P7g/uH+4f7h/uL+4v7i/uP+4/7k/uT+5P7l/uX+5f7m/ub+5v7SABgBGAEXARcBFwEWARYBFgEVARUBFQEUARQBFAETARMBEwESARIBiADt/u3+7v7u/u7+7/7v/u/+8P7w/vD+8f7x/vH+8v7y/vP+8/7z/vT+hQALAQsBCgEKAQoBCQEJAQkBCAEIAQgBBwEHAQYBBgEGAQUBBQEFAcMA+v77/vv++/78/vz+/P79/v3+/f7+/v7+/v7//v/+//4A/wD/AP8B/z8A/gD9AP0A/QD8APwA/AD7APsA+wD6APoA+gD5APkA+QD4APgA+AD3AAj/CP8I/wn/Cf8J/wr/Cv8K/wv/C/8L/wz/DP8M/w3/Df8N/w7/Dv8AAPEA8ADwAPAA7wDvAO8A7gDuAO0A7QDtAOwA7ADsAOsA6wDrAOoA6gBP/xX/Ff8W/xb/Fv8X/xf/GP8Y/xj/Gf8Z/xn/Gv8a/xr/G/8b/xv/x//jAOMA4wDiAOIA4gDhAOEA4QDgAOAA4ADfAN8A3wDeAN4A3gDdAN0Akf8i/yP/I/8j/yT/JP8k/yX/Jf8l/yb/Jv8m/yf/J/8n/yj/KP8p/5T/1gDWANUA1QDVANQA1ADUANMA0wDTANIA0gDSANEA0QDRANAA0ADQAMv/MP8w/zD/Mf8x/zH/Mv8y/zL/M/8z/zP/NP80/zT/Nf81/zX/Nv9o/8kAyQDIAMgAyADHAMcAxwDGAMYAxgDFAMUAxQDEAMQAxADDAMMAwwD//z3/Pf8+/z7/Pv8//z//P/9A/0D/QP9B/0H/Qf9C/0L/Qv9D/0P/Q/+8ALsAuwC7ALoAugC6ALkAuQC5ALgAuAC4ALcAtwC3ALYAtgC2ALUALQBK/0v/S/9L/0z/TP9M/03/Tf9N/07/Tv9O/0//T/9P/1D/UP9Q/1H/gwCuAK4ArgCtAK0ArQCsAKwArACrAKsAqwCqAKoAqgCpAKkAqQCoAFQAV/9Y/1j/WP9Z/1n/Wf9a/1r/Wv9b/1v/W/9c/1z/XP9d/13/Xf9e/1EAoQChAKAAoACgAJ8AnwCfAJ4AngCeAJ0AnQCdAJwAnACcAJsAmwB0AGX/Zf9l/2b/Zv9m/2f/Z/9n/2j/aP9o/2n/af9p/2r/av9q/2v/a/8lAJQAkwCTAJMAkgCSAJIAkQCRAJEAkACQAJAAjwCPAI8AjgCOAI4AjQBy/3L/c/9z/3P/dP90/3T/df91/3X/dv92/3b/d/93/3f/eP94/3j/AACHAIYAhgCGAIUAhQCFAIQAhACEAIMAgwCDAIIAggCCAIEAgQCBAIAAn/+A/4D/gP+B/4H/gf+C/4L/gv+D/4P/g/+E/4T/hP+F/4X/hf+G/+H/eQB5AHkAeAB4AHgAdwB3AHcAdgB2AHYAdQB1AHUAdAB0AHQAcwBzAMb/jf+N/43/jv+O/47/j/+P/4//kP+Q/5D/kf+R/5H/kv+S/5L/k//J/2wAbABsAGsAawBrAGoAagBqAGkAaQBpAGgAaABoAGcAZwBnAGYAZgDm/5r/mv+b/5v/m/+c/5z/nP+d/53/nf+e/57/nv+f/5//n/+g/6D/uP9fAF8AXgBeAF4AXQBdAF0AXABcAFwAWwBbAFsAWgBaAFoAWQBZAFkAAACn/6j/qP+o/6n/qf+p/6r/qv+q/6v/q/+r/6z/rP+s/63/rf+t/67/UgBRAFEAUQBQAFAAUABPAE8ATwBPAE4ATgBOAE0ATQBNAEwATABMABMAtf+1/7X/tv+2/7b/t/+3/7f/uP+4/7j/uf+5/7n/uv+6/7r/u/+7/zMARABEAEQAQwBDAEMAQgBCAEIAQQBBAEEAQABAAEAAPwA/AD8APgAfAML/wv/D/8P/w//E/8T/xP/F/8X/xf/F/8b/xv/G/8f/x//H/8j/yP8cADcANwA2ADYANgA1ADUANQA0ADQANAAzADMAMwAyADIAMgAyADEAJQDP/8//0P/Q/9D/0f/R/9H/0v/S/9L/0//T/9P/1P/U/9T/1f/V/9X/CgAqACoAKQApACkAKAAoACgAJwAnACcAJgAmACYAJQAlACUAJAAkACQA3P/d/93/3f/e/97/3v/f/9//3//g/+D/4P/h/+H/4f/i/+L/4v/j/wAAHQAcABwAHAAbABsAGwAaABoAGgAZABkAGQAYABgAGAAXABcAFwAXAO//6v/q/+v/6//r/+z/7P/s/+3/7f/t/+7/7v/u/+//7//v//D/8P/8/w8ADwAPAA4ADgAOAA4ADQANAA0ADAAMAAwACwALAAsACgAKAAoACQD7//f/+P/4//j/+f/5//n/+v/6//r/+//7//v/+//8//z//P/9//3///8CAAIAAgABAAEAAQAAAAAAAAA='; +const mp3DataUri = + 'data:audio/mpeg;base64,//uQZAAAAxA11q0xAAQAAA0goAABGjGVYVmpgBAAADSDAAAAACAAliWJYliWZv4sMxDBMAcAMCYjk8/DwUMRK9L0rcsXBuAoAoDQUFERK//3tEIFBQUFBRHd3v///0FAbh+Lnu73////LuWLuHlO+JwcBAEAQ/lAQBAHw//B8HwfP/6wcBBFGWoABJSVpIoAABKost935KY8kOY7QiI2DADhsMXCHvcUIw0gEcjZUZUykTJAcw0RDRQJqYgmQTJ4Dwi5EaY6RAUWcB/KYXKIHRia3I4DHUDE0QGDLQzoekDfhkTFx7H8YwWUVBmx0DGj4CWAIuEpCtiKk8LiBqIKsKqSz6AhcG5qSa58gIJHC+xdLpoWTwysRUb9lOx9QRKHnUtTk+bF4kUVVoJDnmDjPmvtIgdkNLfXkkZUroGJMJWoVkijrZREfeZmvXUPfnZZPtFv/5X/+WV9BgOIT2Vd2ZTFLsWlzDhUDACsAQOA4LLGYvDrgjNGy0WHjqpGyxjQbRAI5gZsQF1IfUZk1apeaJZ17pJl0BxEDFqAuqFxOilQ//uSZCSD9e9tUidygAAAAA0g4AABFY21R83WkIAAADSAAAAEvWeqWXtExKgyoCWAhhskk7HXesqVyi9UxFmgUOgWLDiedRRSW8vpZRK1A1aHVAxYAcRPOhmSLzp5lqIldpdIaAQJFWZl9lsddqzStZAT7LrIaCQEFBRPOigpZszzNFpRG0k6KJiTAFhxWSqzFz860xUKii5k9IAgCSotTylLpSlRZKZyWdJjAALPF+zVgFPp+VrQLPzDtOrTkcIXSsn+ECFCgjJUPN7Kyk2d7sbidAC4okhbZ8zXrLVUq+ZCzgMa5DpS8ilnXao7sRj+sJHwYP6lsWkM1assltI6i6Q5oIphCGzJNKa3nTy1pDYZS1LJQEBwmpqk86fPrUXa0BeJM11hnoMEuujWVDme1kqN911xfANEC0kztLCj861ZkJzTl9WLtyAFhyNVQDHUkTLcqznFZThfgT8mZTS+IzCUyRULHjVgcMwMAlPxWkv+Zkie1MwPB0ecQw3EoycF4wBA81OjYABQxyNbu5XpfhdplbXcqIIVYl6mGWgOMYZbsP/7kmQuAPXiadhzuVR2AAANIAAAARsdqU/OZm3IAAA0gAAABDS/69/lPq73tJNsaanAlPUxxt4V606rewe5WsdpN3L1t/HVi1mn/Pu8/qYXKTB9H4BfBAYVCUfAVRKNR59XnnJ/IBFjIqZ///oTlh5P//n+w+NmSMnQz+noxKZ/mFR++p/pQACQABwAAaAjBIKOd2Y8TijKZSMsmIwSCjBYKYcw1MVHkwEBwoJTWoePVgxwjDCMgkxBzGDAALkv6/r+v7Gn+f6NP8/0NP8/zsuSw1hoUBMTcIyQWa81lxZTDstlMtxpcr89ErVyXYyq5XwvqJoIbk4TLoIJJUlmSkUaCKNHekmWA5ML+DweRWiyLIpJIUCkkkktFFFFFEuiMQWWXUUf9GamjrpJKSSMi8OUK6CIwzReNkkkkkUUWRQNTU3SdJJJJI4kTIYtGkXkn/01InSRRtW1GZC1liqqIAiIBgQEQAAP/hnSyluih8wOneHiEie7FvkqaVrPkqS5Z7aqCywELLAk7jNB3HySFW5Ij+5HnnsqqyykDRDgWJBi5KD/+5JkIgH192tPcP2pQgAADSAAAAEW0a89yFqLiAAANIAAAATBdVnSOGJKksblpKtbaKzIEA1BZeCJDRLy1XW6zhPnRsDHEgkpCy51joW5BhXL8sWutlqHs4PAd40VPKZcvkNGCDURgWKxNsXLLc5TWO4lDQNwSSc1VWxVSJoJCcWgssVaK6nSWRYlC2HsCim1FTosksyBoEQRDRE8TLYgDxIIJDkBb+zniZGsCaRgJj5AagkK3Jgm+RrZ1AfyFZ21UrrLATGaBIWR1Ro46R4qnjpEx/kafa+qks4CXsEYQuqIYNnq5LkqSI3UV3sqk0EBkGaA6xETtrqqWRKNkZ8kV0bZxAphegF0STmda3sgdLBYJYiQ3k9TaBdFtCK8CxIsub011MyyIEokGpDsNEjdVah2pGAND4FkhDXL11ucoxvFgkgiAL659TnZizBxIOoEyxKLb1Vi64UMQgAD56//7/eVXoYXBA6rIPR5TN2LdpGmtNEtD+QpYp3V0FlAJREBhTIqdyGjxXoESG6aEwf0lbI8rCkmLelFgp05v67/NXZR//uSZCYB9kJrzmO12+IAAA0gAAABFvGtPerTGcgAADSAAAAEHXyhmT6/D+a5dwn0qSMhQ+ltB/f7r94wiYe1bNupu/rmvp8I2KFQtdVtUn9325+eDyvRLU74rhu1r9fR2FukCKgMsWpb3LfM+ZXIZgyRu+p6a1rXfuTnbQwLjT5FaOI9w3f1y5ch/J5ktlGbn2Pv18qD5ejIPOsM24L2xsgB5gGBQgYBb+zl0ho+g65QBrCwG9kHJjkED5ZLToHFkqW0mQWgrusyCBkgWFy1FpIojYrnjg0B/NCObbXSmwIK4L0DRQzg8vddaiRMR4HwbLtZdTG4IAQKxRGRfM+7so4YlglioS66lNZCZgksAtSsb2XU7KImSiYoUk0rKqolAUUEkoImhcUXrLZdFx6KRJhk5VXNbqOFFRsMqDGBOGxQo3Wy1kWMyXE3i3HVI3eZ1kDBst6/LF0wBpkGAxjQAJP///87GX1VkJQDkhKnYaAyW76V+WTTWfG6S5ZZ7aqCykDRxikjGbOajAdB1GZBRuksXy0lWptFFIChPBaGGihKif/7kmQlAfYSa87ztaPyAAANIAAAARbZrzuK0x7IAAA0gAAABBVdbqUT6xvDqNFXsuZoHQQJgLyRGQ2y5Zbrpx6JQkBLDZp5Vc3QMQghASPFGVaK66R0ixKFsgA30VHVPYvD4GRBAZBTgeYma1st1LIIobYfiNN5vdRwvKYEgQKZSGSbvc7QjZKJJhEGXD0+tzaaKLoSNCCpGHy2xVAb8YARAW/s5qTJDhFSUCGfgM84OLHQQPlltaA/ltF2uqq6ywCTZAsJC7NXLg8OqidHslSWJVe+qkzgUGIUjBVQFYKrXVWsnlDyQ8tLpW0FFMEnQFzooYVNPXZ2OlwlDw5RIPTVXPKL4RhCCBMpG11sujIQsEkHkSSn7rWiPxqGEQqAJmWL2U61EVOj0LMGLN7y339RfG6QYF61OSzuGu49z1D8lzWBU93ljf2uWsq5DQWvEuSn2ALthQxEAAH/dnNSZG4IiPgJmrAKlkH/HQZ2kaa02Ko3S2UHe6r0ESkCAiYEhKFV13DwMLezKCUIY4OmccddK4WxEPk77JqRKaz3+65rcIn/+5JkJ4H2XWvN4sveEgAADSAAAAEWwa076tM/AAAANIAAAASHtaLbw+9rmvp8I2OMwZMsKUZnO93+v7qBXw2zOE8/LXddtb8hVhJUitqM91vn8yuRuae5kTmXd0n399mHq6gwPWMZtx/72uXPw62ePwMWAKmsds77dykWNOWDJDWvZk/MdfrvLr/QbOKqLd3y7vlrGl+8Mhg8zxaigLAP0IA8sDAYwMA/9kiKjkk0L0lAjQwGa0HfHQQPlk9qWN0tmTNQV0ll4El+CJKHpdSGoNhzs1SG8WSWJR12sukxuO0FoaTiApy99NRiWCWNC2v9lHQlOAslIcJq9l1WUSZHG4vTdKynVRngTWApgKiRlayq1kScexXx5Va6nWRxCg0JAismh8uVqZTOdIoSqQrw2FKTXWmPTGoQnALCzNZdupjlKQhRJAM8MzKbWXMnNZOUlS+zHy16AAaIBRIIgAA/9nIqOSJEGckcErmArlg/pKL5GmmtIfz5mz21aJeBAywROA1pJDUIR7qMymWSELx5V7LrZMCgQBSSk4gIkbtddUly//uSZCWB9clrznK0z8IAAA0gAAABF7mtN2rTPogAADSAAAAEUJYbiTXVXTUUQncFdMxAxq7LZbJHS8ShLlQhUVrU+ZVAhQAuUNJRp2U6ljwdHsP6brn1uqXCaFGCSgCzUoOb3VXZRBSVPCexgoUltMzdZiCZQFJ59yaqUymWog6x4DihU1zSyLmdIdFGwon8o0wAL+IAKH/skTpARjgx0fQQaUA/pCeSOJu0strNBuuYu19VJEyAo1QRSwnXMVDUJZ1KWxJHRvEearvbUgZgUGwFpqaICREnW+s6YD8WiBFp7KrmqimDXIBZmP4eJK62XRkIWB6EsNlT91zFMsAVCBV6XWKF7LdM4USVIUiI3zT+b7r7d9mxLwu2axovw193uWnvkshRdr5cvb5q7KseozFPca+S/j97fLl2NXISwNi1jK7rl7tJuYJWxJKhvy9HEAaFBiMIEQAAD/2SJ0ckjhnSOBpaQFYkGyj6MXZyNNbqNR/cwd6SnfZICijBaMFp3QDxGviFCYiHB5XjrqNNRfBKABfCeQFOJOurUSJKGo+zVv/7kmQpAfXLa056q6YSAAANIAAAARcVrzeK0xnIAAA0gAAABLK71g1kApfGqJkXe2pRfMiFHKJBakLrmCZcD9wXIl1zOymXQceiPNhICsnNV1zUvjCBqoAsXPmperU90TpQMSWFmjbLNOyjFGaB7YLWi8gUL2O02HkoE6F4nFzZU0c/QCAqHqIGxOtEC7ipDFD/6RdJkZYOJH0CWVAPnwciPom3ZIfJ7UmP8yZrKddJjYCilB1YKjmKhdFp11uSLDEIYfXa2gozBpcAvVLiArw9svuonx+WOk3e6qqM1BDCBc2LSKMlayq1kScexnyRVRup00y4CEuCMazlytVbOdIgP6hGRII3XXSI8UYE0AIrZfRKN7qqURFh4D8BtVpWWYkEdgakwRRjc8T7KWy3Y4QclDMOJFQaaKoS6iViB43CUZSr2BAGiAQTGBEAAA/9kiKjKiegvSRwJVcBlJCEpSR5ZNNaQ3WM2ejdWyQFDuFJgedJQrBaddR0n1DeI5lXsumoxCCoBcCYIDWIVtdlmxKHyYLbfpUQTQBUuKaPRj2VUsr/+5JkLwH1d2tOerTGcAAADSAAAAEVoa03iq6YCAAANIAAAASLJcXpuu91UEzgNUIIopyb3VVZROlk8QMhEqrZiSpPAmEB183lK11VqJM4SAiRpsplolWXgmXC0opseorqd1l4sFsYwYJtWp7GS1L5KcUnKFIAv4iZhR/0idICMsGQSOBBfQFVcIKk0Z2SI00ssqDdPFF2vqpM4FDiFLCWk1YB5a45YXHBvF88n+yiyEtAFsJyQIb9desqEoWxrIPa66LqBCMB7YP6Txy9l1qLp0lyoS6FS+6zAIWYFjJeY9ZVV5ClkkRK0UbWUpIjWJIHci9Kdd7ILLBYJYgw2DBSaq5CJGAQHQxgUXNrrc5RjycJIM8MVza6bHaxCAFoJqxMVAAGlwdFPBIIAB/Qpa7MBMhmhyJh3KLwu1ZdFIfJVucKouzxgfSWpTvsmCTsH/TzuoXzVVKKhQHkfKaraqajMJyARCc4S/5wuj8S5bJZdd7nEy4LKBbsLSo7bXy2dNh+S29qARQkDPsbd+swOlohpbVTsqZlE8Gjgs2SmF9d5VLC//uSZEAA9RprTvg8obAAAA0gAAABFT2vN4rulggAADSAAAAEItZVSuqpR51BAHBYgauXdbqqUX3KwrxCqtdOhNwzgFApis3AG/DbWUAA/2SLpARlg5EhwFK2AyHhZJeW6KRGntSYuy0Uu2v+8tjB6Rps5mZoCXM66aZdJQbxoW1/nlGIQ4B8uxsQrauWykSY/oqvdVc0BMGCpYUKaK7XdZgSpsQ4tbrrpXCBaCI2boo3uupRVRJAZwkl3tZRRIQISYImTsb667KJ8sG4kJXXPKdSi7NgtqDoBcSLFrrZayaMysOWMJalrd6dEIjBlVpnrdUABokFQyoJAAAf6KRFRlQ5IBHEhFRwztIow9Z7jlBN7/3k+NFnzLW/7v98zGBGPTmxm6huNXrLZYJYlO+u6jEGtgFB6Szo9frQOEuTJ9drqqnAalQRdhnSQTvq1FUsx1lp6S+jcGiEFn5+Zd11qK6iWF8aKtZdM1GiEjIXuOsesuqksyOFstEKnXdRxJUnAc9Lkp3uuqSJ03DvmTUl0rKMQhDAoDScvADbhNqBIP9EyP/7kmRYAfU3a036HKLgAAANIAAAARU9rTWq0nlAAAA0gAAABLpFRWwXRIcA6ugKdYXCXlqRSI09y0N0lija91aBmCAHgippomZoCXemgZD8QheLT2XXNVHQTsApHNlk2PXXyqolyMPqtfWswCFSBbENUbyHq1FcssNVNrLqpKWCRUKYy9O2uqpZkcLRbJZPV2QGiEHxAAoubXWyq5InCSEsKx1aV6i/Og0ACsUGLlSnW7qJ8srEYjYeeWyUydMMHA9ZNSihAG/EcdBQAAD/NHhJAa4ScsSC1BDMUmrD17WOUzZ/tyy2WDrF/W9f3/3qu0kaltjOgNbVqLQ/FUfaLW96IJyAdjPpkqSX6jeS5HMu/oLMAmtBEuHyN4/69ZeLJ40Lau2dUXwvyDrxsxy91VyqWDUX5q1JVVIwHkGhMFjBq5d7KqUT7lYUMVDC11pGzJhIUCICtE/ZVTssvHTxABsrrsqdmhWByovMWJQBbQpBCj/RMi6RUc4QqOUARfwDtkINJ5lGJkPkq2MnJUtHO2vUUwvwBKwlxMzYXz91mY/GpMn/+5JkboH1NmtNaPyicgAADSAAAAEVMa8xitMZwAAANIAAAAT911UXPAhnBVGT6x9kk1lVLNlkuTZaV+8yCUECwYoiotdVd5LFEkBdJLvZdSi+QQFOybGevdlF8lFi0lfZVVEsDtBIeDphcSMrWWy1l4zLZFxvKUtb3JdjUJlhy1pmt1VUpImJIBkhdMptZphZJcfNbvU/FQBJgohggAP8okHQBYchJcIwFvwNykkYteuVaaCb3/vUlk3eZf//v96j44HgjIyjOw1nr5WG4xGH2uvpOeAjOB+IrnRTSS2VWsqHSXGqvbVQWXgaUQ6RQmJtdT3RUZnSEHKKiSkLLmaB0EhgFvRZmll13WTxKHxyCFRUu+ZD6NQvyDupclm9l1R5LJXDylVCkupMtrLoNTQKIzZyaqUymWoiceA1IYa56yMzl4JkQs0SrFqSADlAgUAIf6mDoAuUqV2jAV/BVWbWe5cpa0d5//RPi9tDzH+//P+5JSQVBFBqZpsPp+6kB+kOZ7a6KZgBVgCs4rpjVKz21KROls1LbfutQJQQWUEOFFRt//uSZIUB9Zhry2C8onIAAA0gAAABFZGtLaLyicAAADSAAAAEdVUrLJEfZVXa2gozCA0BaEXXT11syyWLBWFOSSvddEjjYVwFKrMYVWumovllxPJJponl1LJ5FMEwwUdl6UbWWyll44SJAxhIJLVRc8iYBANDpCZcvJUD4IoAgAAH+okHQBjTus6MHZ8BTpZsivXKtarz/1i+L5Wb+t8/+a3cg0qkIFUW5uw6j9XNRdG5Dy1rXZM4mTAFUQE9xPl8UKl18rFkrD8bbrros4FIIUwjlCil7vrWVECRFpTVeyqc3CawCRItGxrrbSUdKRLkCJZa0LrcwIcSQSFASiorN7Kqdlk8SpoOQN8xrW0xXQE3ApSJ5AsXsumxIlAuhhyeXSVZJK4NBYOSFU+TI4AQgANBR3//9SyIR95I2jOYPApgKYmNw4PA1k9tMvmSJ6ykh9jwUH9k0jQdAzID1wBckTYSxYHYMcW73Onx+K4+VvddRmszFcBIYCNUJ3Jsaq0/Zaiy5eNGsutBJhW4NVIOsjaJYqNuupRdUWjQrp6tbHBWgP/7kmSUAfWaa0pQvKJyAAANIAAAARb1rSmVygAIAAA0goAABJIhChLOlayq5PKJMdZLKtfOl4aYQDAsdNqF1VUllU6VBTitvZaJgpxrgWRE3M+2yjcsJieSWaynVMWL4ioOQjmKLCwC20puydBLdlQuFxNlL//4AQSbNmX6Mmi6Bq8ZzD4cvALqCDW0SYJ/O5HdZa+SObYlbQ+S+HKT/SmKC/tWZLHkcEQESCU5WldphA0pkKr59KtThBRrj8Mvf/9PGARRrn/ikDM+46uGYAca92J9q7/VCOJ/PX/khg66uHIcRTdOcMsuDn6139JTR/v/W7/p/IyNcijX43D7EHIGDMevfuxJs9rTDByKe5+9Y/uTNK2hvD9uWSzCH5fDyjEs/889b44kn/Wqai1//jz+QFz/1c5/M69qpSfNzn4YUmef6g53/6P/6/H4/9xY8KMC/wIKfsc/+5CN//Qn/7HPF3///kb//8UIn//93//6bdIiRLLaQbYUUgkiUAWVInPu3Y5mzl8R6HA3EDgGjp+mQg2wGSWYl65pfFagWAg4HVj/+5JknYAHGGfS7msEBCZHenDFFAAZtg9NuZkAEzAt6/89UgI3sLCLlIaQQgg5YBsh84IxMSkHFAuOPweE2IcR4hVwAmCw4VkMai9GbIUY8sCxB0ooIQGGPRnRnk0bmAW8lmgiTRPGUuqLxeMSAiNK3zAMhfcojpao6iovCvC3PdbMoahFjEwRWamJQIMKMbJUU1Uy+wwuttEvTI2WtlpIlImW6tEybrf/Kbdf+pFT1opLSLKn////lk9////UYmqqzKTiLKbQTgWkzMadTiTIFAQMBMBi7aS9Q+MFsIIwCAa6QgACryozkEBzMBEeQIZXYyBhAWgYmDYuAplkkQMMB0IQMDZoBQFgFBMNWGxkmBhMOBq0RmBoIH9kEXMRCYMjA3KJoMUmi3TnS0ViaKIBgBEZAJEIHFQqBEZExcoiABfZVzF2czJgvKL4QB136hSCSloImRsGkstR49Mi+KDP0j7VFFL+senrKRutFIBgAsW+tLHyIHN/+tWmaLoCuGytjZLpPWVj1L+VdycNyWOIMgAGMzi5cuuSQRAiTkMGokmM//uSRAsP8lYNyu8wwABJYljl5KQAAAABpAAAACAAADSAAAAETRkSgCqiVa5GR4aLA0OBp4KhoqGqw3wah2JToKnfqPcFR4K/5Y8VDZV3/g1xKdEv5Y9/+IgoCZUpWqziwqQxjlIiILBqAWBImmhZpEhjlSlZCKWhKGolKgrBo8IuVDSgaCgNPDQiO4lDZV3xEeET8q4RLBbiJ2JTv9R5P5UNqkxBTUUzLjEwMKqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqg=='; + +class SourcesTab extends StatefulWidget { + final AudioPlayer player; + + const SourcesTab({ + required this.player, + super.key, + }); + + @override + State createState() => _SourcesTabState(); +} + +class _SourcesTabState extends State + with AutomaticKeepAliveClientMixin { + AudioPlayer get player => widget.player; + + final List sourceWidgets = []; + + Future _setSource(Source source) async { + await player.setSource(source); + toast( + 'Completed setting source.', + textKey: const Key('toast-set-source'), + ); + } + + Future _play(Source source) async { + await player.stop(); + await player.play(source); + toast( + 'Set and playing source.', + textKey: const Key('toast-set-play'), + ); + } + + Future _removeSourceWidget(Widget sourceWidget) async { + setState(() { + sourceWidgets.remove(sourceWidget); + }); + toast('Source removed.'); + } + + Widget _createSourceTile({ + required String title, + required String subtitle, + required Source source, + Key? setSourceKey, + Color? buttonColor, + Key? playKey, + }) => + _SourceTile( + setSource: () => _setSource(source), + play: () => _play(source), + removeSource: _removeSourceWidget, + title: title, + subtitle: subtitle, + setSourceKey: setSourceKey, + playKey: playKey, + buttonColor: buttonColor, + ); + + Future _setSourceBytesAsset( + Future Function(Source) fun, { + required String asset, + String? mimeType, + }) async { + final bytes = await AudioCache.instance.loadAsBytes(asset); + await fun(BytesSource(bytes, mimeType: mimeType)); + } + + Future _setSourceBytesRemote( + Future Function(Source) fun, { + required String url, + String? mimeType, + }) async { + final bytes = await http.readBytes(Uri.parse(url)); + await fun(BytesSource(bytes, mimeType: mimeType)); + } + + @override + void initState() { + super.initState(); + sourceWidgets.addAll( + [ + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-wav-1'), + title: 'Remote URL WAV 1', + subtitle: 'coins.wav', + source: UrlSource(wavUrl1), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-wav-2'), + title: 'Remote URL WAV 2', + subtitle: 'laser.wav', + source: UrlSource(wavUrl2), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-mp3-1'), + title: 'Remote URL MP3 1 (VBR)', + subtitle: 'ambient_c_motion.mp3', + source: UrlSource(mp3Url1), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-mp3-2'), + title: 'Remote URL MP3 2', + subtitle: 'nasa_on_a_mission.mp3', + source: UrlSource(mp3Url2), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-m3u8'), + title: 'Remote URL M3U8', + subtitle: 'HLS Low-Latency Live Stream', + source: UrlSource(m3u8StreamUrl), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-remote-mpga'), + title: 'Remote URL MPGA', + subtitle: 'Times stream', + source: UrlSource(mpgaStreamUrl), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-data-wav'), + title: 'Data URI WAV', + subtitle: 'coins.wav', + source: UrlSource(wavDataUri), + ), + _createSourceTile( + setSourceKey: const Key('setSource-url-data-mp3'), + title: 'Data URI MP3', + subtitle: 'coins.mp3', + source: UrlSource(mp3DataUri), + ), + _createSourceTile( + setSourceKey: const Key('setSource-asset-wav'), + title: 'Asset WAV', + subtitle: 'laser.wav', + source: AssetSource(wavAsset2), + ), + _createSourceTile( + setSourceKey: const Key('setSource-asset-mp3'), + title: 'Asset MP3', + subtitle: 'nasa.mp3', + source: AssetSource(mp3Asset), + ), + _SourceTile( + setSource: () => _setSourceBytesAsset( + _setSource, + asset: wavAsset2, + mimeType: 'audio/wav', + ), + setSourceKey: const Key('setSource-bytes-local'), + play: () => _setSourceBytesAsset( + _play, + asset: wavAsset2, + mimeType: 'audio/wav', + ), + removeSource: _removeSourceWidget, + title: 'Bytes - Local', + subtitle: 'laser.wav', + ), + _SourceTile( + setSource: () => _setSourceBytesRemote( + _setSource, + url: mp3Url1, + mimeType: 'audio/mpeg', + ), + setSourceKey: const Key('setSource-bytes-remote'), + play: () => _setSourceBytesRemote( + _play, + url: mp3Url1, + mimeType: 'audio/mpeg', + ), + removeSource: _removeSourceWidget, + title: 'Bytes - Remote', + subtitle: 'ambient.mp3', + ), + _createSourceTile( + setSourceKey: const Key('setSource-asset-invalid'), + title: 'Invalid Asset', + subtitle: 'invalid.txt', + source: AssetSource(invalidAsset), + buttonColor: Colors.red, + ), + ], + ); + } + + @override + Widget build(BuildContext context) { + super.build(context); + return Stack( + alignment: Alignment.bottomCenter, + children: [ + TabContent( + children: sourceWidgets + .expand((element) => [element, const Divider()]) + .toList(), + ), + Padding( + padding: const EdgeInsets.all(16), + child: FloatingActionButton( + child: const Icon(Icons.add), + onPressed: () { + dialog( + _SourceDialog( + onAdd: (Source source, String path) { + setState(() { + sourceWidgets.add( + _createSourceTile( + title: source.runtimeType.toString(), + subtitle: path, + source: source, + ), + ); + }); + }, + ), + ); + }, + ), + ), + ], + ); + } + + @override + bool get wantKeepAlive => true; +} + +class _SourceTile extends StatelessWidget { + final void Function() setSource; + final void Function() play; + final void Function(Widget sourceWidget) removeSource; + final String title; + final String? subtitle; + final Key? setSourceKey; + final Key? playKey; + final Color? buttonColor; + + const _SourceTile({ + required this.setSource, + required this.play, + required this.removeSource, + required this.title, + this.subtitle, + this.setSourceKey, + this.playKey, + this.buttonColor, + }); + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text(title), + subtitle: subtitle != null ? Text(subtitle!) : null, + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + tooltip: 'Set Source', + key: setSourceKey, + onPressed: setSource, + icon: const Icon(Icons.upload_file), + color: buttonColor ?? Theme.of(context).primaryColor, + ), + IconButton( + key: playKey, + tooltip: 'Play', + onPressed: play, + icon: const Icon(Icons.play_arrow), + color: buttonColor ?? Theme.of(context).primaryColor, + ), + IconButton( + tooltip: 'Remove', + onPressed: () => removeSource(this), + icon: const Icon(Icons.delete), + color: buttonColor ?? Theme.of(context).primaryColor, + ), + ], + ), + ); + } +} + +class _SourceDialog extends StatefulWidget { + final void Function(Source source, String path) onAdd; + + const _SourceDialog({required this.onAdd}); + + @override + State<_SourceDialog> createState() => _SourceDialogState(); +} + +class _SourceDialogState extends State<_SourceDialog> { + Type sourceType = UrlSource; + String path = ''; + + final Map assetsList = {'': 'Nothing selected'}; + + @override + void initState() { + super.initState(); + + AssetManifest.loadFromAssetBundle(rootBundle).then((assetManifest) { + setState(() { + assetsList.addAll( + assetManifest + .listAssets() + .map((e) => e.replaceFirst('assets/', '')) + .toList() + .asMap() + .map((key, value) => MapEntry(value, value)), + ); + }); + }); + } + + Widget _buildSourceValue() { + switch (sourceType) { + case const (AssetSource): + return Row( + children: [ + const Text('Asset path'), + const SizedBox(width: 16), + Expanded( + child: CustomDropDown( + options: assetsList, + selected: path, + onChange: (value) => setState(() { + path = value ?? ''; + }), + ), + ), + ], + ); + case const (BytesSource): + case const (DeviceFileSource): + return Row( + children: [ + const Text('Device File path'), + const SizedBox(width: 16), + Expanded(child: Text(path)), + TextButton.icon( + onPressed: () async { + final result = await FilePicker.platform.pickFiles(); + final path = result?.files.single.path; + if (path != null) { + setState(() { + this.path = path; + }); + } + }, + icon: const Icon(Icons.file_open), + label: const Text('Browse'), + ), + ], + ); + default: + return Row( + children: [ + const Text('URL'), + const SizedBox(width: 16), + Expanded( + child: TextField( + decoration: const InputDecoration( + hintText: 'https://example.com/myFile.wav', + ), + onChanged: (String? url) => path = url ?? '', + ), + ), + ], + ); + } + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + LabeledDropDown( + label: 'Source type', + options: const { + AssetSource: 'Asset', + DeviceFileSource: 'Device File', + UrlSource: 'Url', + BytesSource: 'Byte Array', + }, + selected: sourceType, + onChange: (Type? value) { + setState(() { + if (value != null) { + sourceType = value; + } + }); + }, + ), + ListTile(title: _buildSourceValue()), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Btn( + onPressed: () async { + switch (sourceType) { + case const (BytesSource): + widget.onAdd( + BytesSource(await File(path).readAsBytes()), + path, + ); + case const (AssetSource): + widget.onAdd(AssetSource(path), path); + case const (DeviceFileSource): + widget.onAdd(DeviceFileSource(path), path); + default: + widget.onAdd(UrlSource(path), path); + } + Navigator.of(context).pop(); + }, + txt: 'Add', + ), + TextButton( + onPressed: Navigator.of(context).pop, + child: const Text('Cancel'), + ), + ], + ), + ], + ); + } +} diff --git a/packages/audioplayers/example/lib/tabs/streams.dart b/packages/audioplayers/example/lib/tabs/streams.dart new file mode 100644 index 000000000..f679067f9 --- /dev/null +++ b/packages/audioplayers/example/lib/tabs/streams.dart @@ -0,0 +1,28 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_example/components/player_widget.dart'; +import 'package:audioplayers_example/components/properties_widget.dart'; +import 'package:audioplayers_example/components/stream_widget.dart'; +import 'package:audioplayers_example/components/tab_content.dart'; +import 'package:flutter/material.dart'; + +class StreamsTab extends StatelessWidget { + final AudioPlayer player; + + const StreamsTab({ + required this.player, + super.key, + }); + + @override + Widget build(BuildContext context) { + return TabContent( + children: [ + PlayerWidget(player: player), + const Divider(), + StreamWidget(player: player), + const Divider(), + PropertiesWidget(player: player), + ], + ); + } +} diff --git a/packages/audioplayers/example/lib/utils.dart b/packages/audioplayers/example/lib/utils.dart new file mode 100644 index 000000000..a8cfbcdb8 --- /dev/null +++ b/packages/audioplayers/example/lib/utils.dart @@ -0,0 +1,42 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_example/components/dlg.dart'; +import 'package:flutter/material.dart'; + +extension StateExt on State { + void toast(String message, {Key? textKey}) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(message, key: textKey), + duration: Duration(milliseconds: message.length * 25), + ), + ); + } + + void simpleDialog(String message, [String action = 'Ok']) { + showDialog( + context: context, + builder: (_) { + return SimpleDlg(message: message, action: action); + }, + ); + } + + void dialog(Widget child) { + showDialog( + context: context, + builder: (_) { + return Dlg(child: child); + }, + ); + } +} + +extension PlayerStateIcon on PlayerState { + IconData getIcon() { + return this == PlayerState.playing + ? Icons.play_arrow + : (this == PlayerState.paused + ? Icons.pause + : (this == PlayerState.stopped ? Icons.stop : Icons.stop_circle)); + } +} diff --git a/packages/audioplayers/example/linux/.gitignore b/packages/audioplayers/example/linux/.gitignore new file mode 100644 index 000000000..d3896c984 --- /dev/null +++ b/packages/audioplayers/example/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/packages/audioplayers/example/linux/CMakeLists.txt b/packages/audioplayers/example/linux/CMakeLists.txt new file mode 100644 index 000000000..d84b870f1 --- /dev/null +++ b/packages/audioplayers/example/linux/CMakeLists.txt @@ -0,0 +1,145 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "audioplayers_example") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "xyz.luan.audioplayers.example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/linux/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() diff --git a/packages/audioplayers/example/linux/flutter/CMakeLists.txt b/packages/audioplayers/example/linux/flutter/CMakeLists.txt new file mode 100644 index 000000000..d5bd01648 --- /dev/null +++ b/packages/audioplayers/example/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/packages/audioplayers/example/linux/flutter/generated_plugin_registrant.cc b/packages/audioplayers/example/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..1830e5c73 --- /dev/null +++ b/packages/audioplayers/example/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); +} diff --git a/packages/audioplayers/example/linux/flutter/generated_plugin_registrant.h b/packages/audioplayers/example/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..e0f0a47bc --- /dev/null +++ b/packages/audioplayers/example/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/audioplayers/example/linux/flutter/generated_plugins.cmake b/packages/audioplayers/example/linux/flutter/generated_plugins.cmake new file mode 100644 index 000000000..e9abb9128 --- /dev/null +++ b/packages/audioplayers/example/linux/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/audioplayers/example/linux/main.cc b/packages/audioplayers/example/linux/main.cc new file mode 100644 index 000000000..e7c5c5437 --- /dev/null +++ b/packages/audioplayers/example/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/packages/audioplayers/example/linux/my_application.cc b/packages/audioplayers/example/linux/my_application.cc new file mode 100644 index 000000000..316f3bef7 --- /dev/null +++ b/packages/audioplayers/example/linux/my_application.cc @@ -0,0 +1,127 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "AudioPlayers Example"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "AudioPlayers Example"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments( + project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, + gchar*** arguments, + int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GApplication::startup. +static void my_application_startup(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application startup. + + G_APPLICATION_CLASS(my_application_parent_class)->startup(application); +} + +// Implements GApplication::shutdown. +static void my_application_shutdown(GApplication* application) { + // MyApplication* self = MY_APPLICATION(object); + + // Perform any actions required at application shutdown. + + G_APPLICATION_CLASS(my_application_parent_class)->shutdown(application); +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = + my_application_local_command_line; + G_APPLICATION_CLASS(klass)->startup = my_application_startup; + G_APPLICATION_CLASS(klass)->shutdown = my_application_shutdown; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, "flags", + G_APPLICATION_NON_UNIQUE, nullptr)); +} diff --git a/packages/audioplayers/example/linux/my_application.h b/packages/audioplayers/example/linux/my_application.h new file mode 100644 index 000000000..db16367a7 --- /dev/null +++ b/packages/audioplayers/example/linux/my_application.h @@ -0,0 +1,21 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, + my_application, + MY, + APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/packages/audioplayers/example/macos/.gitignore b/packages/audioplayers/example/macos/.gitignore index d2fd37723..746adbb6b 100644 --- a/packages/audioplayers/example/macos/.gitignore +++ b/packages/audioplayers/example/macos/.gitignore @@ -3,4 +3,5 @@ **/Pods/ # Xcode-related +**/dgph **/xcuserdata/ diff --git a/packages/audioplayers/example/macos/Flutter/Flutter-Debug.xcconfig b/packages/audioplayers/example/macos/Flutter/Flutter-Debug.xcconfig index 785633d3a..4b81f9b2d 100644 --- a/packages/audioplayers/example/macos/Flutter/Flutter-Debug.xcconfig +++ b/packages/audioplayers/example/macos/Flutter/Flutter-Debug.xcconfig @@ -1,2 +1,2 @@ -#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/audioplayers/example/macos/Flutter/Flutter-Release.xcconfig b/packages/audioplayers/example/macos/Flutter/Flutter-Release.xcconfig index 5fba960c3..5caa9d157 100644 --- a/packages/audioplayers/example/macos/Flutter/Flutter-Release.xcconfig +++ b/packages/audioplayers/example/macos/Flutter/Flutter-Release.xcconfig @@ -1,2 +1,2 @@ -#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "ephemeral/Flutter-Generated.xcconfig" diff --git a/packages/audioplayers/example/macos/Flutter/GeneratedPluginRegistrant.swift b/packages/audioplayers/example/macos/Flutter/GeneratedPluginRegistrant.swift index 2119932de..277e21f3a 100644 --- a/packages/audioplayers/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/packages/audioplayers/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,12 @@ import FlutterMacOS import Foundation -import audioplayers -import path_provider_macos +import audioplayers_darwin +import file_picker +import path_provider_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - AudioplayersPlugin.register(with: registry.registrar(forPlugin: "AudioplayersPlugin")) + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) } diff --git a/packages/audioplayers/example/macos/Podfile b/packages/audioplayers/example/macos/Podfile index dade8dfad..b52666a10 100644 --- a/packages/audioplayers/example/macos/Podfile +++ b/packages/audioplayers/example/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.11' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -31,6 +31,9 @@ target 'Runner' do use_modular_headers! flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + target 'RunnerTests' do + inherit! :search_paths + end end post_install do |installer| diff --git a/packages/audioplayers/example/macos/Podfile.lock b/packages/audioplayers/example/macos/Podfile.lock index 575ea293e..9d359e124 100644 --- a/packages/audioplayers/example/macos/Podfile.lock +++ b/packages/audioplayers/example/macos/Podfile.lock @@ -1,28 +1,16 @@ PODS: - - audioplayers (0.0.1): - - FlutterMacOS - FlutterMacOS (1.0.0) - - path_provider_macos (0.0.1): - - FlutterMacOS DEPENDENCIES: - - audioplayers (from `Flutter/ephemeral/.symlinks/plugins/audioplayers/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - - path_provider_macos (from `Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos`) EXTERNAL SOURCES: - audioplayers: - :path: Flutter/ephemeral/.symlinks/plugins/audioplayers/macos FlutterMacOS: :path: Flutter/ephemeral - path_provider_macos: - :path: Flutter/ephemeral/.symlinks/plugins/path_provider_macos/macos SPEC CHECKSUMS: - audioplayers: 8b48e22684b6e0d9df143b2d1bbd61dca9dab6b4 - FlutterMacOS: 57701585bf7de1b3fc2bb61f6378d73bbdea8424 - path_provider_macos: a0a3fd666cb7cd0448e936fb4abad4052961002b + FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1 -PODFILE CHECKSUM: 6eac6b3292e5142cfc23bdeb71848a40ec51c14c +PODFILE CHECKSUM: 9ebaf0ce3d369aaa26a9ea0e159195ed94724cf3 -COCOAPODS: 1.10.1 +COCOAPODS: 1.16.2 diff --git a/packages/audioplayers/example/macos/Runner.xcodeproj/project.pbxproj b/packages/audioplayers/example/macos/Runner.xcodeproj/project.pbxproj index ae10a6511..6f70b590c 100644 --- a/packages/audioplayers/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/audioplayers/example/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ @@ -21,15 +21,25 @@ /* End PBXAggregateTarget section */ /* Begin PBXBuildFile section */ - 1CFF2593EC4F7D036CDEEE23 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63DD5086589C7A5450A5F49A /* Pods_Runner.framework */; }; + 3228F8EE8B7B3FEBB392A835 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8F6D8242811D95D06FEF024D /* Pods_Runner.framework */; }; + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C80D7294CF71000263BE5 /* RunnerTests.swift */; }; 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + A9FC904455E7690B9D3FACD5 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6C459AF0A5961555926C470E /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 33CC10E52044A3C60003C045 /* Project object */; @@ -53,9 +63,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 194D3B2A2814BAF03598A7C8 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + 32C7BA0D7105CCD1DC4E7F8C /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* audioplayers_example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = audioplayers_example.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* AudioPlayers Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "AudioPlayers Example.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -67,26 +81,46 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; - 63DD5086589C7A5450A5F49A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 3AB8E600D81E00A85A58509A /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 509E2DD799426DB0ED0B1251 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 6C459AF0A5961555926C470E /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 8F6D8242811D95D06FEF024D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; - AE29A370CDF6BEBDA60333C1 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - D7059FDE42F995B3D35E68A8 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - FDD10DC00EB8DEE530B505B7 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + A515AB09778703EA4CD3AEF6 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + DA3151FA94152FADC66AF729 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 331C80D2294CF70F00263BE5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + A9FC904455E7690B9D3FACD5 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 33CC10EA2044A3C60003C045 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1CFF2593EC4F7D036CDEEE23 /* Pods_Runner.framework in Frameworks */, + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, + 3228F8EE8B7B3FEBB392A835 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 331C80D6294CF71000263BE5 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 331C80D7294CF71000263BE5 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; 33BA886A226E78AF003329D5 /* Configs */ = { isa = PBXGroup; children = ( @@ -103,16 +137,18 @@ children = ( 33FAB671232836740065AC1E /* Runner */, 33CEB47122A05771004F2AC0 /* Flutter */, + 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, - 4EE09F690BFE2EDC6B8A0655 /* Pods */, + 8FDE069EF1BABCD311F89848 /* Pods */, ); sourceTree = ""; }; 33CC10EE2044A3C60003C045 /* Products */ = { isa = PBXGroup; children = ( - 33CC10ED2044A3C60003C045 /* audioplayers_example.app */, + 33CC10ED2044A3C60003C045 /* AudioPlayers Example.app */, + 331C80D5294CF71000263BE5 /* RunnerTests.xctest */, ); name = Products; sourceTree = ""; @@ -131,6 +167,7 @@ 33CEB47122A05771004F2AC0 /* Flutter */ = { isa = PBXGroup; children = ( + 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */, 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, @@ -152,12 +189,15 @@ path = Runner; sourceTree = ""; }; - 4EE09F690BFE2EDC6B8A0655 /* Pods */ = { + 8FDE069EF1BABCD311F89848 /* Pods */ = { isa = PBXGroup; children = ( - AE29A370CDF6BEBDA60333C1 /* Pods-Runner.debug.xcconfig */, - FDD10DC00EB8DEE530B505B7 /* Pods-Runner.release.xcconfig */, - D7059FDE42F995B3D35E68A8 /* Pods-Runner.profile.xcconfig */, + A515AB09778703EA4CD3AEF6 /* Pods-Runner.debug.xcconfig */, + 509E2DD799426DB0ED0B1251 /* Pods-Runner.release.xcconfig */, + DA3151FA94152FADC66AF729 /* Pods-Runner.profile.xcconfig */, + 194D3B2A2814BAF03598A7C8 /* Pods-RunnerTests.debug.xcconfig */, + 3AB8E600D81E00A85A58509A /* Pods-RunnerTests.release.xcconfig */, + 32C7BA0D7105CCD1DC4E7F8C /* Pods-RunnerTests.profile.xcconfig */, ); name = Pods; path = Pods; @@ -166,7 +206,8 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( - 63DD5086589C7A5450A5F49A /* Pods_Runner.framework */, + 8F6D8242811D95D06FEF024D /* Pods_Runner.framework */, + 6C459AF0A5961555926C470E /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -174,17 +215,35 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 331C80D4294CF70F00263BE5 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + C972DEEBB788E0F68237181C /* [CP] Check Pods Manifest.lock */, + 331C80D1294CF70F00263BE5 /* Sources */, + 331C80D2294CF70F00263BE5 /* Frameworks */, + 331C80D3294CF70F00263BE5 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 331C80DA294CF71000263BE5 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 331C80D5294CF71000263BE5 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 33CC10EC2044A3C60003C045 /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 78086EAC071DDF78C3B7D9FA /* [CP] Check Pods Manifest.lock */, + 3EF0669A1CF967670CBB83A9 /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - BBAB6A286DD0AF4BFA652E76 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -192,8 +251,11 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; - productReference = 33CC10ED2044A3C60003C045 /* audioplayers_example.app */; + productReference = 33CC10ED2044A3C60003C045 /* AudioPlayers Example.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ @@ -202,10 +264,15 @@ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { + BuildIndependentTargetsInParallel = YES; LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 0930; - ORGANIZATIONNAME = "The Flutter Authors"; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = "Blue Fire"; TargetAttributes = { + 331C80D4294CF70F00263BE5 = { + CreatedOnToolsVersion = 14.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; 33CC10EC2044A3C60003C045 = { CreatedOnToolsVersion = 9.2; LastSwiftMigration = 1100; @@ -223,7 +290,7 @@ }; }; buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 8.0"; + compatibilityVersion = "Xcode 9.3"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -231,17 +298,28 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 33CC10EC2044A3C60003C045 /* Runner */, + 331C80D4294CF70F00263BE5 /* RunnerTests */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 331C80D3294CF70F00263BE5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 33CC10EB2044A3C60003C045 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -256,6 +334,7 @@ /* Begin PBXShellScriptBuildPhase section */ 3399D490228B24CF009A79C7 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); @@ -291,7 +370,7 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 78086EAC071DDF78C3B7D9FA /* [CP] Check Pods Manifest.lock */ = { + 3EF0669A1CF967670CBB83A9 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -313,29 +392,39 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - BBAB6A286DD0AF4BFA652E76 /* [CP] Embed Pods Frameworks */ = { + C972DEEBB788E0F68237181C /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", - "${BUILT_PRODUCTS_DIR}/audioplayers/audioplayers.framework", - "${BUILT_PRODUCTS_DIR}/path_provider_macos/path_provider_macos.framework", + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "[CP] Embed Pods Frameworks"; outputPaths = ( - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/audioplayers.framework", - "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_macos.framework", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 331C80D1294CF70F00263BE5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 331C80D8294CF71000263BE5 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 33CC10E92044A3C60003C045 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -349,6 +438,11 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 331C80DA294CF71000263BE5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 331C80D9294CF71000263BE5 /* PBXContainerItemProxy */; + }; 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; @@ -369,11 +463,57 @@ /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ + 331C80DB294CF71000263BE5 /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 194D3B2A2814BAF03598A7C8 /* Pods-RunnerTests.debug.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/audioplayers_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/audioplayers_example"; + }; + name = Debug; + }; + 331C80DC294CF71000263BE5 /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 3AB8E600D81E00A85A58509A /* Pods-RunnerTests.release.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/audioplayers_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/audioplayers_example"; + }; + name = Release; + }; + 331C80DD294CF71000263BE5 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 32C7BA0D7105CCD1DC4E7F8C /* Pods-RunnerTests.profile.xcconfig */; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.example.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/audioplayers_example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/audioplayers_example"; + }; + name = Profile; + }; 338D0CE9231458BD00FA5F75 /* Profile */ = { isa = XCBuildConfiguration; baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -397,9 +537,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -407,7 +549,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -424,10 +566,6 @@ CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter/ephemeral", - ); INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -451,6 +589,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -474,9 +613,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -490,7 +631,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; @@ -504,6 +645,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; @@ -527,9 +669,11 @@ CLANG_WARN_SUSPICIOUS_MOVE = YES; CODE_SIGN_IDENTITY = "-"; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = NO; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -537,7 +681,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - MACOSX_DEPLOYMENT_TARGET = 10.11; + MACOSX_DEPLOYMENT_TARGET = 10.15; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; @@ -554,10 +698,6 @@ CODE_SIGN_ENTITLEMENTS = Runner/DebugProfile.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter/ephemeral", - ); INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -578,10 +718,6 @@ CODE_SIGN_ENTITLEMENTS = Runner/Release.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; - FRAMEWORK_SEARCH_PATHS = ( - "$(inherited)", - "$(PROJECT_DIR)/Flutter/ephemeral", - ); INFOPLIST_FILE = Runner/Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -611,6 +747,16 @@ /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ + 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 331C80DB294CF71000263BE5 /* Debug */, + 331C80DC294CF71000263BE5 /* Release */, + 331C80DD294CF71000263BE5 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -642,6 +788,20 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/packages/audioplayers/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/audioplayers/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index daaa87678..efe86df27 100644 --- a/packages/audioplayers/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/audioplayers/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,10 +1,28 @@ + + + + + + + + + + @@ -27,29 +45,28 @@ selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" shouldUseLaunchSchemeArgsEnv = "YES"> - - - - - - - - + + + + + + - - diff --git a/packages/audioplayers/example/macos/Runner/AppDelegate.swift b/packages/audioplayers/example/macos/Runner/AppDelegate.swift index d53ef6437..b3c176141 100644 --- a/packages/audioplayers/example/macos/Runner/AppDelegate.swift +++ b/packages/audioplayers/example/macos/Runner/AppDelegate.swift @@ -1,9 +1,13 @@ import Cocoa import FlutterMacOS -@NSApplicationMain +@main class AppDelegate: FlutterAppDelegate { override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index a2ec33f19..96d3fee1a 100644 --- a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,68 +1,68 @@ { - "images" : [ - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_16.png", - "scale" : "1x" + "info": { + "version": 1, + "author": "xcode" }, - { - "size" : "16x16", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "2x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_32.png", - "scale" : "1x" - }, - { - "size" : "32x32", - "idiom" : "mac", - "filename" : "app_icon_64.png", - "scale" : "2x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_128.png", - "scale" : "1x" - }, - { - "size" : "128x128", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "2x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_256.png", - "scale" : "1x" - }, - { - "size" : "256x256", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "2x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_512.png", - "scale" : "1x" - }, - { - "size" : "512x512", - "idiom" : "mac", - "filename" : "app_icon_1024.png", - "scale" : "2x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} + "images": [ + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_16.png", + "scale": "1x" + }, + { + "size": "16x16", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "2x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_32.png", + "scale": "1x" + }, + { + "size": "32x32", + "idiom": "mac", + "filename": "app_icon_64.png", + "scale": "2x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_128.png", + "scale": "1x" + }, + { + "size": "128x128", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "2x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_256.png", + "scale": "1x" + }, + { + "size": "256x256", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "2x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_512.png", + "scale": "1x" + }, + { + "size": "512x512", + "idiom": "mac", + "filename": "app_icon_1024.png", + "scale": "2x" + } + ] +} \ No newline at end of file diff --git a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 3c4935a7c..0b34c5757 100644 Binary files a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index ed4cc1642..97fc690f3 100644 Binary files a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 483be6138..ce61330c4 100644 Binary files a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index bcbf36df2..765740a60 100644 Binary files a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index 9c0a65286..6113787d4 100644 Binary files a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index e71a72613..305ff8c41 100644 Binary files a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index 8a31fe2dd..cf6c27274 100644 Binary files a/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/packages/audioplayers/example/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/packages/audioplayers/example/macos/Runner/Base.lproj/MainMenu.xib b/packages/audioplayers/example/macos/Runner/Base.lproj/MainMenu.xib index 537341abf..80e867a4e 100644 --- a/packages/audioplayers/example/macos/Runner/Base.lproj/MainMenu.xib +++ b/packages/audioplayers/example/macos/Runner/Base.lproj/MainMenu.xib @@ -323,6 +323,10 @@ + + + + diff --git a/packages/audioplayers/example/macos/Runner/Configs/AppInfo.xcconfig b/packages/audioplayers/example/macos/Runner/Configs/AppInfo.xcconfig index 8b9577c3f..d9d31a753 100644 --- a/packages/audioplayers/example/macos/Runner/Configs/AppInfo.xcconfig +++ b/packages/audioplayers/example/macos/Runner/Configs/AppInfo.xcconfig @@ -5,10 +5,10 @@ // 'flutter create' template. // The application's name. By default this is also the title of the Flutter window. -PRODUCT_NAME = audioplayers_example +PRODUCT_NAME = AudioPlayers Example // The application's bundle identifier -PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.audioplayersExample +PRODUCT_BUNDLE_IDENTIFIER = xyz.luan.audioplayers.example // The copyright displayed in application information -PRODUCT_COPYRIGHT = Copyright © 2020 xyz.luan.audioplayers. All rights reserved. +PRODUCT_COPYRIGHT = Copyright © 2025 xyz.luan.audioplayers. All rights reserved. diff --git a/packages/audioplayers/example/macos/Runner/Info.plist b/packages/audioplayers/example/macos/Runner/Info.plist index 4789daa6a..90a8d2900 100644 --- a/packages/audioplayers/example/macos/Runner/Info.plist +++ b/packages/audioplayers/example/macos/Runner/Info.plist @@ -22,6 +22,11 @@ $(FLUTTER_BUILD_NUMBER) LSMinimumSystemVersion $(MACOSX_DEPLOYMENT_TARGET) + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSHumanReadableCopyright $(PRODUCT_COPYRIGHT) NSMainNibFile diff --git a/packages/audioplayers/example/macos/Runner/MainFlutterWindow.swift b/packages/audioplayers/example/macos/Runner/MainFlutterWindow.swift index 2722837ec..3cc05eb23 100644 --- a/packages/audioplayers/example/macos/Runner/MainFlutterWindow.swift +++ b/packages/audioplayers/example/macos/Runner/MainFlutterWindow.swift @@ -3,7 +3,7 @@ import FlutterMacOS class MainFlutterWindow: NSWindow { override func awakeFromNib() { - let flutterViewController = FlutterViewController.init() + let flutterViewController = FlutterViewController() let windowFrame = self.frame self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) diff --git a/packages/audioplayers/example/macos/RunnerTests/RunnerTests.swift b/packages/audioplayers/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000..61f3bd1fc --- /dev/null +++ b/packages/audioplayers/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,12 @@ +import Cocoa +import FlutterMacOS +import XCTest + +class RunnerTests: XCTestCase { + + func testExample() { + // If you add code to the Runner application, consider adding tests here. + // See https://developer.apple.com/documentation/xctest for more information about using XCTest. + } + +} diff --git a/packages/audioplayers/example/pubspec.yaml b/packages/audioplayers/example/pubspec.yaml index 8f8446261..118366b68 100644 --- a/packages/audioplayers/example/pubspec.yaml +++ b/packages/audioplayers/example/pubspec.yaml @@ -1,29 +1,34 @@ name: audioplayers_example +resolution: workspace description: Demonstrates how to use the audioplayers plugin. +publish_to: none dependencies: + audioplayers: ^6.5.1 + collection: ^1.16.0 + file_picker: ^8.0.3 flutter: sdk: flutter - audioplayers: - path: ../ - http: ^0.13.1 - path_provider: ^2.0.1 - path_provider_macos: ^2.0.0 - provider: 5.0.0 + http: ^1.0.0 + path_provider: ^2.0.12 + provider: ^6.0.5 dev_dependencies: + # Integration tests for audioplayers_platform_interface are handled + # in this package to avoid maintaining multiple example apps: + audioplayers_platform_interface: ^7.1.1 + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter integration_test: sdk: flutter - flame_lint: 0.0.1 flutter: uses-material-design: true assets: - - assets/audio.mp3 - - assets/audio2.mp3 - - assets/messenger.mp3 + - assets/ environment: - sdk: ">=2.14.0 <3.0.0" - flutter: ">=1.10.0" \ No newline at end of file + sdk: ^3.6.0 + flutter: '>=3.27.0' diff --git a/packages/audioplayers/example/server/.gitignore b/packages/audioplayers/example/server/.gitignore new file mode 100644 index 000000000..31d3cdb68 --- /dev/null +++ b/packages/audioplayers/example/server/.gitignore @@ -0,0 +1,14 @@ +# https://dart.dev/guides/libraries/private-files +.DS_Store +.atom/ +.idea +.packages +.pub/ +.dart_tool/ +build/ +ios/.generated/ +packages +pubspec.lock +.flutter-plugins +flutter_export_environment.sh +.last_build_id diff --git a/packages/audioplayers/example/server/README.md b/packages/audioplayers/example/server/README.md new file mode 100644 index 000000000..448f8eb22 --- /dev/null +++ b/packages/audioplayers/example/server/README.md @@ -0,0 +1,14 @@ +A simple HTTP server to provide audio sources using [package:shelf](https://pub.dev/packages/shelf). +This server listens to loop-back (localhost, 127.0.0.1). + +To run this server locally, run as follows: + +```bash +$ dart run bin/server.dart +``` + +Environment variables: + +- `LATENCY`: the timeout until the server should respond in milliseconds, default: `0`. +- `PORT`: the port the server should listen on, default: `8080`. +- `LOG_REQUESTS`: log the network requests, default: `false`. diff --git a/packages/audioplayers/example/server/analysis_options.yaml b/packages/audioplayers/example/server/analysis_options.yaml new file mode 100644 index 000000000..85732fa02 --- /dev/null +++ b/packages/audioplayers/example/server/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/audioplayers/example/server/bin/server.dart b/packages/audioplayers/example/server/bin/server.dart new file mode 100644 index 000000000..ca4fb2424 --- /dev/null +++ b/packages/audioplayers/example/server/bin/server.dart @@ -0,0 +1,60 @@ +// ignore_for_file: avoid_print +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:shelf/shelf_io.dart' as shelf_io; +import 'package:shelf_router/shelf_router.dart' as shelf_router; +import 'package:shelf_static/shelf_static.dart' as shelf_static; + +import 'stream_route.dart'; + +Future main() async { + final port = int.parse(Platform.environment['PORT'] ?? '8080'); + final requestTimeoutMillis = + int.parse(Platform.environment['LATENCY'] ?? '0'); + final isLogRequests = + (Platform.environment['LOG_REQUESTS'] ?? 'false') == 'true'; + + final publicStaticHandler = shelf_static.createStaticHandler( + 'public', + defaultDocument: 'index.html', + serveFilesOutsidePath: true, + ); + + final recordMode = bool.parse(Platform.environment['RECORD_MODE'] ?? 'false'); + final liveMode = + recordMode || bool.parse(Platform.environment['LIVE_MODE'] ?? 'false'); + final routeHandler = shelf_router.Router() + ..mount( + '/stream', + StreamRoute(isLiveMode: liveMode, isRecordMode: recordMode).pipeline, + ); + + final cascade = Cascade().add(publicStaticHandler).add(routeHandler.call); + + var pipeline = const Pipeline(); + if (isLogRequests) { + pipeline = pipeline.addMiddleware(logRequests()); + } + + final handler = pipeline + .addMiddleware( + (innerHandler) => (req) async { + await Future.delayed( + Duration(milliseconds: requestTimeoutMillis), + ); + return await innerHandler(req); + }, + ) + .addHandler(cascade.handler); + + final server = await shelf_io.serve( + handler, + InternetAddress.loopbackIPv4, + port, + ); + + print( + 'Serving at http://${server.address.host}:${server.port} with latency of $requestTimeoutMillis ms', + ); +} diff --git a/packages/audioplayers/example/server/bin/stream_route.dart b/packages/audioplayers/example/server/bin/stream_route.dart new file mode 100644 index 000000000..c0df8f4f3 --- /dev/null +++ b/packages/audioplayers/example/server/bin/stream_route.dart @@ -0,0 +1,152 @@ +// ignore_for_file: avoid_print +import 'dart:async'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:async/async.dart'; +import 'package:shelf/shelf.dart'; +import 'package:shelf_router/shelf_router.dart'; + +class StreamRoute { + static const timesRadioUrl = 'https://timesradio.wireless.radio/stream'; + static const mpegRecordPath = 'public/files/live_streams/mpeg-record.bin'; + + final mpegStreamController = StreamController>.broadcast(); + + StreamRoute({bool isLiveMode = false, bool isRecordMode = false}) + : assert(!isRecordMode || isLiveMode) { + if (isRecordMode) { + recordLiveStream(); + } + if (isLiveMode) { + playLiveStream(); + } else { + playLocalStream(); + } + } + + Future recordLiveStream() async { + const recordingTime = Duration(seconds: 10); + // Save lists of bytes in a file, where each first four bytes indicate the + // length of its following list. + final recordOutput = File(mpegRecordPath); + final fileBytes = []; + final mpegSub = mpegStreamController.stream.listen((bytes) async { + fileBytes.addAll([...int32ToBytes(bytes.length), ...bytes]); + }); + Future.delayed(recordingTime).then((value) async { + print('Recording finished'); + await mpegSub.cancel(); + await recordOutput.writeAsBytes( + fileBytes, + flush: true, + ); + }); + } + + Uint8List int32ToBytes(int value) => + Uint8List(4)..buffer.asInt32List()[0] = value; + + int bytesToInt32(List bytes) => + Uint8List.fromList(bytes).buffer.asInt32List()[0]; + + Future playLiveStream() async { + final client = HttpClient(); + final request = await client.getUrl(Uri.parse(timesRadioUrl)); + final response = await request.close(); + mpegStreamController.addStream(response); + } + + Future playLocalStream() async { + final recordInput = File(mpegRecordPath); + final streamReader = ChunkedStreamReader(recordInput.openRead()); + final fileSize = await recordInput.length(); + var position = 0; + final mpegBytes = >[]; + while (position < fileSize) { + final chunkLength = bytesToInt32(await streamReader.readChunk(4)); + final chunk = await streamReader.readChunk(chunkLength); + position += chunkLength + 4; + mpegBytes.add(chunk); + } + var mpegBytesPosition = 0; + Timer.periodic(const Duration(milliseconds: 150), (timer) { + mpegStreamController.add(mpegBytes[mpegBytesPosition]); + mpegBytesPosition++; + if (mpegBytesPosition >= mpegBytes.length) { + mpegBytesPosition = 0; + } + }); + } + + Router get router { + final router = Router(); + router.get('/wav', (Request request) async { + final range = request.headers['range']; + const contentType = {'Content-Type': 'audio/wav'}; + final file = File('public/files/audio/laser.wav'); + if (range != null) { + final fileSize = await file.length(); + + final parts = range.replaceFirst('bytes=', '').split('-'); + final start = int.parse(parts[0]); + final end = int.tryParse(parts[1]) ?? fileSize - 1; + + if (start >= fileSize) { + return Response( + 416, + body: 'Requested range not satisfiable\n$start >= $fileSize', + ); + } + + final streamReader = ChunkedStreamReader(file.openRead()); + final chunkLength = end - start + 1; + final head = { + 'Content-Range': 'bytes $start-$end/$fileSize', + 'Accept-Ranges': 'bytes', + 'Content-Length': '$chunkLength', + ...contentType, + }; + if (start > 0) { + await streamReader.readChunk(start); + } + final res = Response.ok( + await streamReader.readChunk(chunkLength), + headers: head, + ); + return res; + } else { + final bytes = await file.readAsBytes(); + final fileSize = bytes.length; + final head = { + 'Content-Length': '$fileSize', + ...contentType, + }; + final res = Response.ok( + bytes.toList(), + headers: head, + ); + return res; + } + }); + + router.get('/mpeg', (Request request) async { + const contentType = {'Content-Type': 'audio/mpeg'}; + + final head = { + 'Accept-Ranges': 'bytes', + ...contentType, + }; + final res = Response.ok( + mpegStreamController.stream, + headers: head, + ); + return res; + }); + return router; + } + + Handler get pipeline { + return const Pipeline().addHandler(router.call); + } +} diff --git a/packages/audioplayers/example/server/public/favicon_64.png b/packages/audioplayers/example/server/public/favicon_64.png new file mode 100644 index 000000000..43d2ffa07 Binary files /dev/null and b/packages/audioplayers/example/server/public/favicon_64.png differ diff --git a/packages/audioplayers/example/server/public/files/LICENSE b/packages/audioplayers/example/server/public/files/LICENSE new file mode 120000 index 000000000..147761543 --- /dev/null +++ b/packages/audioplayers/example/server/public/files/LICENSE @@ -0,0 +1 @@ +../../../../LICENSE \ No newline at end of file diff --git a/packages/audioplayers/example/server/public/files/audio/ambient_c_motion.mp3 b/packages/audioplayers/example/server/public/files/audio/ambient_c_motion.mp3 new file mode 120000 index 000000000..8d957238f --- /dev/null +++ b/packages/audioplayers/example/server/public/files/audio/ambient_c_motion.mp3 @@ -0,0 +1 @@ +../../../../assets/ambient_c_motion.mp3 \ No newline at end of file diff --git a/packages/audioplayers/example/server/public/files/audio/coins whitespace.wav b/packages/audioplayers/example/server/public/files/audio/coins whitespace.wav new file mode 120000 index 000000000..3ee7032f7 --- /dev/null +++ b/packages/audioplayers/example/server/public/files/audio/coins whitespace.wav @@ -0,0 +1 @@ +../../../../assets/coins whitespace.wav \ No newline at end of file diff --git a/packages/audioplayers/example/server/public/files/audio/coins.wav b/packages/audioplayers/example/server/public/files/audio/coins.wav new file mode 120000 index 000000000..4686e7fa6 --- /dev/null +++ b/packages/audioplayers/example/server/public/files/audio/coins.wav @@ -0,0 +1 @@ +../../../../assets/coins.wav \ No newline at end of file diff --git a/packages/audioplayers/example/server/public/files/audio/coins_no_extension b/packages/audioplayers/example/server/public/files/audio/coins_no_extension new file mode 120000 index 000000000..46e1773c9 --- /dev/null +++ b/packages/audioplayers/example/server/public/files/audio/coins_no_extension @@ -0,0 +1 @@ +../../../../assets/coins_no_extension \ No newline at end of file diff --git "a/packages/audioplayers/example/server/public/files/audio/coins_non_ascii_\320\270.wav" "b/packages/audioplayers/example/server/public/files/audio/coins_non_ascii_\320\270.wav" new file mode 120000 index 000000000..68c497a71 --- /dev/null +++ "b/packages/audioplayers/example/server/public/files/audio/coins_non_ascii_\320\270.wav" @@ -0,0 +1 @@ +../../../../assets/coins_non_ascii_и.wav \ No newline at end of file diff --git a/packages/audioplayers/example/server/public/files/audio/invalid.txt b/packages/audioplayers/example/server/public/files/audio/invalid.txt new file mode 120000 index 000000000..0c2b4fa70 --- /dev/null +++ b/packages/audioplayers/example/server/public/files/audio/invalid.txt @@ -0,0 +1 @@ +../../../../assets/invalid.txt \ No newline at end of file diff --git a/packages/audioplayers/example/server/public/files/audio/laser.wav b/packages/audioplayers/example/server/public/files/audio/laser.wav new file mode 120000 index 000000000..b9d3ddf50 --- /dev/null +++ b/packages/audioplayers/example/server/public/files/audio/laser.wav @@ -0,0 +1 @@ +../../../../assets/laser.wav \ No newline at end of file diff --git a/packages/audioplayers/example/server/public/files/audio/nasa_on_a_mission.mp3 b/packages/audioplayers/example/server/public/files/audio/nasa_on_a_mission.mp3 new file mode 120000 index 000000000..ad044258d --- /dev/null +++ b/packages/audioplayers/example/server/public/files/audio/nasa_on_a_mission.mp3 @@ -0,0 +1 @@ +../../../../assets/nasa_on_a_mission.mp3 \ No newline at end of file diff --git a/packages/audioplayers/example/server/public/files/live_streams/mpeg-record.bin b/packages/audioplayers/example/server/public/files/live_streams/mpeg-record.bin new file mode 100644 index 000000000..77da6527e Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/mpeg-record.bin differ diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers.m3u8 b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers.m3u8 new file mode 100644 index 000000000..46e196934 --- /dev/null +++ b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers.m3u8 @@ -0,0 +1,25 @@ +#EXTM3U +#EXT-X-VERSION:3 +#EXT-X-TARGETDURATION:6 +#EXT-X-MEDIA-SEQUENCE:0 +#EXT-X-PLAYLIST-TYPE:VOD +#EXTINF:6.400000, +nasa_power_of_the_rovers0.ts +#EXTINF:6.400000, +nasa_power_of_the_rovers1.ts +#EXTINF:6.400000, +nasa_power_of_the_rovers2.ts +#EXTINF:6.400000, +nasa_power_of_the_rovers3.ts +#EXTINF:6.400000, +nasa_power_of_the_rovers4.ts +#EXTINF:6.400000, +nasa_power_of_the_rovers5.ts +#EXTINF:6.400000, +nasa_power_of_the_rovers6.ts +#EXTINF:6.400000, +nasa_power_of_the_rovers7.ts +#EXTINF:6.400000, +nasa_power_of_the_rovers8.ts +#EXTINF:2.410311, +nasa_power_of_the_rovers9.ts diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers0.ts b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers0.ts new file mode 100644 index 000000000..7a17bee38 Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers0.ts differ diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers1.ts b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers1.ts new file mode 100644 index 000000000..44775aaeb Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers1.ts differ diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers2.ts b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers2.ts new file mode 100644 index 000000000..1212ad05c Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers2.ts differ diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers3.ts b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers3.ts new file mode 100644 index 000000000..e459cba7d Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers3.ts differ diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers4.ts b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers4.ts new file mode 100644 index 000000000..a89cac1c0 Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers4.ts differ diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers5.ts b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers5.ts new file mode 100644 index 000000000..dcf458188 Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers5.ts differ diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers6.ts b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers6.ts new file mode 100644 index 000000000..c203c04cf Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers6.ts differ diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers7.ts b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers7.ts new file mode 100644 index 000000000..c46fb8088 Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers7.ts differ diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers8.ts b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers8.ts new file mode 100644 index 000000000..8c9997725 Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers8.ts differ diff --git a/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers9.ts b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers9.ts new file mode 100644 index 000000000..5c7a0dafd Binary files /dev/null and b/packages/audioplayers/example/server/public/files/live_streams/nasa_power_of_the_rovers9.ts differ diff --git a/packages/audioplayers/example/server/public/index.html b/packages/audioplayers/example/server/public/index.html new file mode 100644 index 000000000..d09e095c9 --- /dev/null +++ b/packages/audioplayers/example/server/public/index.html @@ -0,0 +1,91 @@ + + + + + + + + + Audioplayers Test Server + + + + + + + + + + diff --git a/packages/audioplayers/example/server/pubspec.yaml b/packages/audioplayers/example/server/pubspec.yaml new file mode 100644 index 000000000..4107e64da --- /dev/null +++ b/packages/audioplayers/example/server/pubspec.yaml @@ -0,0 +1,16 @@ +name: audioplayers_test_server +resolution: workspace +publish_to: none + +environment: + sdk: ^3.6.0 + +dependencies: + async: ^2.11.0 + shelf: ^1.2.0 + shelf_router: ^1.0.0 + shelf_static: ^1.0.0 + +dev_dependencies: + flame_lint: ^1.4.1 + http: '>=0.13.1 <2.0.0' diff --git a/packages/audioplayers/example/web/favicon.png b/packages/audioplayers/example/web/favicon.png index 8aaa46ac1..ce61330c4 100644 Binary files a/packages/audioplayers/example/web/favicon.png and b/packages/audioplayers/example/web/favicon.png differ diff --git a/packages/audioplayers/example/web/icons/Icon-192.png b/packages/audioplayers/example/web/icons/Icon-192.png index b749bfef0..f1562c0f3 100644 Binary files a/packages/audioplayers/example/web/icons/Icon-192.png and b/packages/audioplayers/example/web/icons/Icon-192.png differ diff --git a/packages/audioplayers/example/web/icons/Icon-512.png b/packages/audioplayers/example/web/icons/Icon-512.png index 88cfd48df..305ff8c41 100644 Binary files a/packages/audioplayers/example/web/icons/Icon-512.png and b/packages/audioplayers/example/web/icons/Icon-512.png differ diff --git a/packages/audioplayers/example/web/icons/Icon-maskable-192.png b/packages/audioplayers/example/web/icons/Icon-maskable-192.png new file mode 100644 index 000000000..a2dff7591 Binary files /dev/null and b/packages/audioplayers/example/web/icons/Icon-maskable-192.png differ diff --git a/packages/audioplayers/example/web/icons/Icon-maskable-512.png b/packages/audioplayers/example/web/icons/Icon-maskable-512.png new file mode 100644 index 000000000..65e3e2e35 Binary files /dev/null and b/packages/audioplayers/example/web/icons/Icon-maskable-512.png differ diff --git a/packages/audioplayers/example/web/index.html b/packages/audioplayers/example/web/index.html index a0c14606f..52c911224 100644 --- a/packages/audioplayers/example/web/index.html +++ b/packages/audioplayers/example/web/index.html @@ -1,6 +1,21 @@ + + + @@ -8,7 +23,7 @@ - + @@ -18,16 +33,6 @@ - - - + diff --git a/packages/audioplayers/example/web/manifest.json b/packages/audioplayers/example/web/manifest.json index 8c012917d..57066682c 100644 --- a/packages/audioplayers/example/web/manifest.json +++ b/packages/audioplayers/example/web/manifest.json @@ -1,6 +1,6 @@ { - "name": "example", - "short_name": "example", + "name": "AudioPlayers Example", + "short_name": "AP Example", "start_url": ".", "display": "standalone", "background_color": "#0175C2", @@ -18,6 +18,18 @@ "src": "icons/Icon-512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "icons/Icon-maskable-192.png", + "sizes": "192x192", + "type": "image/png", + "purpose": "maskable" + }, + { + "src": "icons/Icon-maskable-512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/packages/audioplayers/example/windows/.gitignore b/packages/audioplayers/example/windows/.gitignore new file mode 100644 index 000000000..d492d0d98 --- /dev/null +++ b/packages/audioplayers/example/windows/.gitignore @@ -0,0 +1,17 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/audioplayers/example/windows/CMakeLists.txt b/packages/audioplayers/example/windows/CMakeLists.txt new file mode 100644 index 000000000..51b2b0bc2 --- /dev/null +++ b/packages/audioplayers/example/windows/CMakeLists.txt @@ -0,0 +1,108 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(audioplayers_example LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "audioplayers_example") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(VERSION 3.14...3.25) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Copy the native assets provided by the build.dart from all packages. +set(NATIVE_ASSETS_DIR "${PROJECT_BUILD_DIR}native_assets/windows/") +install(DIRECTORY "${NATIVE_ASSETS_DIR}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/packages/audioplayers/example/windows/flutter/CMakeLists.txt b/packages/audioplayers/example/windows/flutter/CMakeLists.txt new file mode 100644 index 000000000..903f4899d --- /dev/null +++ b/packages/audioplayers/example/windows/flutter/CMakeLists.txt @@ -0,0 +1,109 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# Set fallback configurations for older versions of the flutter tool. +if (NOT DEFINED FLUTTER_TARGET_PLATFORM) + set(FLUTTER_TARGET_PLATFORM "windows-x64") +endif() + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + ${FLUTTER_TARGET_PLATFORM} $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/packages/audioplayers/example/windows/flutter/generated_plugin_registrant.cc b/packages/audioplayers/example/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 000000000..09e8e2c34 --- /dev/null +++ b/packages/audioplayers/example/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,14 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); +} diff --git a/packages/audioplayers/example/windows/flutter/generated_plugin_registrant.h b/packages/audioplayers/example/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 000000000..dc139d85a --- /dev/null +++ b/packages/audioplayers/example/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/packages/audioplayers/example/windows/flutter/generated_plugins.cmake b/packages/audioplayers/example/windows/flutter/generated_plugins.cmake new file mode 100644 index 000000000..375535c98 --- /dev/null +++ b/packages/audioplayers/example/windows/flutter/generated_plugins.cmake @@ -0,0 +1,24 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/packages/audioplayers/example/windows/runner/CMakeLists.txt b/packages/audioplayers/example/windows/runner/CMakeLists.txt new file mode 100644 index 000000000..394917c05 --- /dev/null +++ b/packages/audioplayers/example/windows/runner/CMakeLists.txt @@ -0,0 +1,40 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/packages/audioplayers/example/windows/runner/Runner.rc b/packages/audioplayers/example/windows/runner/Runner.rc new file mode 100644 index 000000000..2ee475040 --- /dev/null +++ b/packages/audioplayers/example/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "xyz.luan.audioplayers" "\0" + VALUE "FileDescription", "AudioPlayers Example" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "AudioPlayers Example" "\0" + VALUE "LegalCopyright", "Copyright (C) 2024 xyz.luan.audioplayers. All rights reserved." "\0" + VALUE "OriginalFilename", "audioplayers_example.exe" "\0" + VALUE "ProductName", "AudioPlayers Example" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/packages/audioplayers/example/windows/runner/flutter_window.cpp b/packages/audioplayers/example/windows/runner/flutter_window.cpp new file mode 100644 index 000000000..b8eef9888 --- /dev/null +++ b/packages/audioplayers/example/windows/runner/flutter_window.cpp @@ -0,0 +1,70 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { this->Show(); }); + + // Flutter can complete the first frame before the "show window" callback is + // registered. The following call ensures a frame is pending to ensure the + // window is shown. It is a no-op if the first frame hasn't completed yet. + flutter_controller_->ForceRedraw(); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/packages/audioplayers/example/windows/runner/flutter_window.h b/packages/audioplayers/example/windows/runner/flutter_window.h new file mode 100644 index 000000000..b7ce53dc2 --- /dev/null +++ b/packages/audioplayers/example/windows/runner/flutter_window.h @@ -0,0 +1,35 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/packages/audioplayers/example/windows/runner/main.cpp b/packages/audioplayers/example/windows/runner/main.cpp new file mode 100644 index 000000000..46b79d112 --- /dev/null +++ b/packages/audioplayers/example/windows/runner/main.cpp @@ -0,0 +1,44 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, + _In_opt_ HINSTANCE prev, + _In_ wchar_t* command_line, + _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"AudioPlayers Example", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/packages/audioplayers/example/windows/runner/resource.h b/packages/audioplayers/example/windows/runner/resource.h new file mode 100644 index 000000000..d5d958dc4 --- /dev/null +++ b/packages/audioplayers/example/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/packages/audioplayers/example/windows/runner/resources/app_icon.ico b/packages/audioplayers/example/windows/runner/resources/app_icon.ico new file mode 100644 index 000000000..7d19c9cbc Binary files /dev/null and b/packages/audioplayers/example/windows/runner/resources/app_icon.ico differ diff --git a/packages/audioplayers/example/windows/runner/runner.exe.manifest b/packages/audioplayers/example/windows/runner/runner.exe.manifest new file mode 100644 index 000000000..a42ea7687 --- /dev/null +++ b/packages/audioplayers/example/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/packages/audioplayers/example/windows/runner/utils.cpp b/packages/audioplayers/example/windows/runner/utils.cpp new file mode 100644 index 000000000..cf06cfe8c --- /dev/null +++ b/packages/audioplayers/example/windows/runner/utils.cpp @@ -0,0 +1,65 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE* unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + unsigned int target_length = + ::WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, -1, + nullptr, 0, nullptr, nullptr) - + 1; // remove the trailing null character + int input_length = (int)wcslen(utf16_string); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, input_length, + utf8_string.data(), target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/packages/audioplayers/example/windows/runner/utils.h b/packages/audioplayers/example/windows/runner/utils.h new file mode 100644 index 000000000..3879d5475 --- /dev/null +++ b/packages/audioplayers/example/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/packages/audioplayers/example/windows/runner/win32_window.cpp b/packages/audioplayers/example/windows/runner/win32_window.cpp new file mode 100644 index 000000000..10b8bd32c --- /dev/null +++ b/packages/audioplayers/example/windows/runner/win32_window.cpp @@ -0,0 +1,290 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: +/// https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = + L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registrar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = + RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, RRF_RT_REG_DWORD, nullptr, + &light_mode, &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/packages/audioplayers/example/windows/runner/win32_window.h b/packages/audioplayers/example/windows/runner/win32_window.h new file mode 100644 index 000000000..e901dde68 --- /dev/null +++ b/packages/audioplayers/example/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_ diff --git a/packages/audioplayers/ios/Classes/AudioplayersPlugin.h b/packages/audioplayers/ios/Classes/AudioplayersPlugin.h deleted file mode 120000 index 74e276007..000000000 --- a/packages/audioplayers/ios/Classes/AudioplayersPlugin.h +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/AudioplayersPlugin.h \ No newline at end of file diff --git a/packages/audioplayers/ios/Classes/AudioplayersPlugin.m b/packages/audioplayers/ios/Classes/AudioplayersPlugin.m deleted file mode 120000 index c94fef942..000000000 --- a/packages/audioplayers/ios/Classes/AudioplayersPlugin.m +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/AudioplayersPlugin.m \ No newline at end of file diff --git a/packages/audioplayers/ios/Classes/Logger.swift b/packages/audioplayers/ios/Classes/Logger.swift deleted file mode 120000 index a8df5c764..000000000 --- a/packages/audioplayers/ios/Classes/Logger.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/Logger.swift \ No newline at end of file diff --git a/packages/audioplayers/ios/Classes/NotificationsHandler.swift b/packages/audioplayers/ios/Classes/NotificationsHandler.swift deleted file mode 120000 index e99210244..000000000 --- a/packages/audioplayers/ios/Classes/NotificationsHandler.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/NotificationsHandler.swift \ No newline at end of file diff --git a/packages/audioplayers/ios/Classes/SwiftAudioplayersPlugin.swift b/packages/audioplayers/ios/Classes/SwiftAudioplayersPlugin.swift deleted file mode 120000 index f07fc31c9..000000000 --- a/packages/audioplayers/ios/Classes/SwiftAudioplayersPlugin.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/SwiftAudioplayersPlugin.swift \ No newline at end of file diff --git a/packages/audioplayers/ios/Classes/Utils.swift b/packages/audioplayers/ios/Classes/Utils.swift deleted file mode 120000 index dd1f0c079..000000000 --- a/packages/audioplayers/ios/Classes/Utils.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/Utils.swift \ No newline at end of file diff --git a/packages/audioplayers/ios/Classes/WrappedMediaPlayer.swift b/packages/audioplayers/ios/Classes/WrappedMediaPlayer.swift deleted file mode 120000 index 3adc9cd87..000000000 --- a/packages/audioplayers/ios/Classes/WrappedMediaPlayer.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/WrappedMediaPlayer.swift \ No newline at end of file diff --git a/packages/audioplayers/ios/audioplayers.podspec b/packages/audioplayers/ios/audioplayers.podspec deleted file mode 100644 index 31edef74d..000000000 --- a/packages/audioplayers/ios/audioplayers.podspec +++ /dev/null @@ -1,23 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint audioplayers.podspec' to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'audioplayers' - s.version = '0.0.1' - s.summary = 'A new flutter plugin project.' - s.description = <<-DESC -A new flutter plugin project. - DESC - s.homepage = 'http://example.com' - s.license = { :file => '../LICENSE' } - s.author = { 'Your Company' => 'email@example.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.dependency 'Flutter' - s.platform = :ios, '8.0' - - # Flutter.framework does not contain a i386 slice. - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } - s.swift_version = '5.0' -end diff --git a/packages/audioplayers/lib/audioplayers.dart b/packages/audioplayers/lib/audioplayers.dart index 8eef13d9b..a55f94626 100644 --- a/packages/audioplayers/lib/audioplayers.dart +++ b/packages/audioplayers/lib/audioplayers.dart @@ -1,4 +1,16 @@ -export 'audioplayers_api.dart'; +export 'package:audioplayers_platform_interface/src/api/audio_context.dart'; +export 'package:audioplayers_platform_interface/src/api/audio_context_config.dart'; +export 'package:audioplayers_platform_interface/src/api/audio_event.dart'; +export 'package:audioplayers_platform_interface/src/api/global_audio_event.dart'; +export 'package:audioplayers_platform_interface/src/api/player_mode.dart'; +export 'package:audioplayers_platform_interface/src/api/player_state.dart'; +export 'package:audioplayers_platform_interface/src/api/release_mode.dart'; + export 'src/audio_cache.dart'; +export 'src/audio_log_level.dart'; +export 'src/audio_logger.dart'; +export 'src/audio_pool.dart'; export 'src/audioplayer.dart'; -export 'src/logger.dart'; +export 'src/global_audio_scope.dart'; +export 'src/position_updater.dart'; +export 'src/source.dart'; diff --git a/packages/audioplayers/lib/audioplayers_api.dart b/packages/audioplayers/lib/audioplayers_api.dart deleted file mode 100644 index 1b608f604..000000000 --- a/packages/audioplayers/lib/audioplayers_api.dart +++ /dev/null @@ -1,5 +0,0 @@ -export 'src/api/log_level.dart'; -export 'src/api/player_mode.dart'; -export 'src/api/player_state.dart'; -export 'src/api/playing_route.dart'; -export 'src/api/release_mode.dart'; diff --git a/packages/audioplayers/lib/notifications.dart b/packages/audioplayers/lib/notifications.dart deleted file mode 100644 index 188391d20..000000000 --- a/packages/audioplayers/lib/notifications.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'src/notifications/notification_service.dart'; -export 'src/notifications/player_control_command.dart'; diff --git a/packages/audioplayers/lib/src/api/log_level.dart b/packages/audioplayers/lib/src/api/log_level.dart deleted file mode 100644 index dc96a3a84..000000000 --- a/packages/audioplayers/lib/src/api/log_level.dart +++ /dev/null @@ -1,14 +0,0 @@ -enum LogLevel { info, error, none } - -extension LogLevelExtension on LogLevel { - int getLevel() { - switch (this) { - case LogLevel.info: - return 2; - case LogLevel.error: - return 1; - case LogLevel.none: - return 0; - } - } -} diff --git a/packages/audioplayers/lib/src/api/player_mode.dart b/packages/audioplayers/lib/src/api/player_mode.dart deleted file mode 100644 index a0b57813d..000000000 --- a/packages/audioplayers/lib/src/api/player_mode.dart +++ /dev/null @@ -1,16 +0,0 @@ -/// This enum is meant to be used as a parameter of the AudioPlayer's -/// constructor. It represents the general mode of the AudioPlayer. -/// -// In iOS and macOS, both modes have the same backend implementation. -enum PlayerMode { - /// Ideal for long media files or streams. - mediaPlayer, - - /// Ideal for short audio files, since it reduces the impacts on visuals or - /// UI performance. - /// - /// In this mode the backend won't fire any duration or position updates. - /// Also, it is not possible to use the seek method to set the audio a - /// specific position. - lowLatency, -} diff --git a/packages/audioplayers/lib/src/api/playing_route.dart b/packages/audioplayers/lib/src/api/playing_route.dart deleted file mode 100644 index d41646c5e..000000000 --- a/packages/audioplayers/lib/src/api/playing_route.dart +++ /dev/null @@ -1,29 +0,0 @@ -/// Indicates which speakers use for playing -enum PlayingRoute { - speakers, - earpiece, -} - -extension PlayingRouteExtensions on PlayingRoute { - /// Returns this enum name to be used over the wire with the native side. - /// TODO(luan) other enums use toString(), we should unify. - String name() { - switch (this) { - case PlayingRoute.speakers: - return 'speakers'; - case PlayingRoute.earpiece: - return 'earpiece'; - } - } - - /// Note: this only makes sense because we have exactly two playing routes. - /// If that ever was to change, this method would need to be removed. - PlayingRoute toggle() { - switch (this) { - case PlayingRoute.speakers: - return PlayingRoute.earpiece; - case PlayingRoute.earpiece: - return PlayingRoute.speakers; - } - } -} diff --git a/packages/audioplayers/lib/src/audio_cache.dart b/packages/audioplayers/lib/src/audio_cache.dart index 6678fba34..b619f8124 100644 --- a/packages/audioplayers/lib/src/audio_cache.dart +++ b/packages/audioplayers/lib/src/audio_cache.dart @@ -1,81 +1,91 @@ import 'dart:async'; -import 'dart:io'; -import 'dart:typed_data'; +import 'package:audioplayers/src/uri_ext.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart' show rootBundle; import 'package:http/http.dart' as http; import 'package:path_provider/path_provider.dart'; +import 'package:uuid/uuid.dart'; -import 'api/player_mode.dart'; -import 'api/release_mode.dart'; -import 'audioplayer.dart'; +const _uuid = Uuid(); /// This class represents a cache for Local Assets to be played. /// /// On desktop/mobile, Flutter can only play audios on device folders, so first -/// this class copies the files to a temporary folder, and then plays them. +/// this class copies asset files to a temporary folder, and then holds a +/// reference to the file. +/// /// On web, it just stores a reference to the URL of the audio, but it gets /// preloaded by making a simple GET request (the browser then takes care of /// caching). /// /// You can pre-cache your audio, or clear the cache, as desired. +/// For most normal uses, the static instance is used. But if you want to +/// control multiple caches, you can create your own instances. class AudioCache { + /// A globally accessible instance used by default by all players. + static AudioCache instance = AudioCache(); + + @visibleForTesting + static FileSystem fileSystem = const LocalFileSystem(); + /// A reference to the loaded files absolute URLs. /// /// This is a map of fileNames to pre-loaded URIs. - /// On mobile/desktop, the URIs are from local files where the bytes have been copied. + /// On mobile/desktop, the URIs are from local files where the bytes have been + /// copied. /// On web, the URIs are external links for pre-loaded files. - Map loadedFiles = {}; + final Map loadedFiles = {}; /// This is the path inside your assets folder where your files lie. /// - /// For example, Flame uses the prefix 'assets/audio/' (you must include the final slash!). + /// For example, Flame uses the prefix 'assets/audio/' + /// (you must include the final slash!). /// The default prefix (if not provided) is 'assets/' - /// Your files will be found at (so the trailing slash is crucial). + /// Your files will be found at `` (so the trailing slash is + /// crucial). String prefix; - /// This is an instance of AudioPlayer that, if present, will always be used. + /// An unique ID generated for this instance of [AudioCache]. /// - /// If not set, the AudioCache will create and return a new instance of AudioPlayer every call, allowing for simultaneous calls. - /// If this is set, every call will overwrite previous calls. - AudioPlayer? fixedPlayer; + /// This is used to load a file into an unique location in the temporary + /// directory. + String? cacheId; - /// This flag should be set to true, if player is used for playing internal notifications - /// - /// This flag will have influence of stream type, and will respect silent mode if set to true. - /// - /// Not implemented on macOS. - bool respectSilence; - - /// This flag should be set to true, if player is used for playing sound while there may be music - /// - /// Defaults to false, meaning the audio will be paused while playing on iOS and continue playing on Android. - bool duckAudio; - - AudioCache({ - this.prefix = 'assets/', - this.fixedPlayer, - this.respectSilence = false, - this.duckAudio = false, - }); + AudioCache({this.prefix = 'assets/', String? cacheId}) + : cacheId = cacheId ?? _uuid.v4(); /// Clears the cache for the file [fileName]. /// /// Does nothing if the file was not on cache. - /// Note: web relies on browser cache which is handled entirely by the browser. - Future clear(Uri fileName) async { - final uri = loadedFiles.remove(fileName); + /// Note: web relies on the browser cache which is handled entirely by the + /// browser, thus this will no-op. + Future clear(String fileName) async { + await _clearFile(fileName); + loadedFiles.remove(fileName); + } + + Future _clearFile(String fileName) async { + final uri = loadedFiles[fileName]; if (uri != null && !kIsWeb) { - await File(uri.toFilePath()).delete(); + await fileSystem.file(uri.toFilePath(windows: false)).delete(); } } /// Clears the whole cache. Future clearAll() async { - await Future.wait(loadedFiles.values.map(clear)); + await Future.wait(loadedFiles.keys.map(_clearFile)); + loadedFiles.clear(); } + @visibleForTesting + Future loadAsset(String path) => rootBundle.load(path); + + @visibleForTesting + Future getTempDir() async => (await getTemporaryDirectory()).path; + Future fetchToMemory(String fileName) async { if (kIsWeb) { final uri = _sanitizeURLForWeb(fileName); @@ -86,10 +96,10 @@ class AudioCache { } // read local asset from rootBundle - final byteData = await rootBundle.load('$prefix$fileName'); + final byteData = await loadAsset('$prefix$fileName'); // create a temporary file on the device to be read by the native side - final file = File('${(await getTemporaryDirectory()).path}/$fileName'); + final file = fileSystem.file('${await getTempDir()}/$cacheId/$fileName'); await file.create(recursive: true); await file.writeAsBytes(byteData.buffer.asUint8List()); @@ -99,24 +109,50 @@ class AudioCache { Uri _sanitizeURLForWeb(String fileName) { final tryAbsolute = Uri.tryParse(fileName); - if (tryAbsolute?.isAbsolute == true) { + if (tryAbsolute?.isAbsolute ?? false) { return tryAbsolute!; } - // local asset - return Uri.parse('assets/$prefix$fileName'); + // Relative Asset path + // URL-encode twice, see: + // https://github.com/flutter/engine/blob/2d39e672c95efc6c539d9b48b2cccc65df290cc4/lib/web_ui/lib/ui_web/src/ui_web/asset_manager.dart#L61 + // Parsing an already encoded string to an Uri does not encode it a second + // time, so we have to do it manually: + final encoded = UriCoder.encodeOnce(fileName); + return Uri.parse(Uri.encodeFull('assets/$prefix$encoded')); } /// Loads a single [fileName] to the cache. /// - /// Also returns a [Future] to access that file. + /// Returns a [Uri] to access that file. Future load(String fileName) async { - if (!loadedFiles.containsKey(fileName)) { + var needsFetch = !loadedFiles.containsKey(fileName); + + // On Android, verify that the cached file still exists. It can be removed + // by the system when the storage is almost full + // see https://developer.android.com/training/data-storage/app-specific#internal-remove-cache + if (!needsFetch && + defaultTargetPlatform == TargetPlatform.android && + !await fileSystem.file(loadedFiles[fileName]).exists()) { + needsFetch = true; + } + + if (needsFetch) { loadedFiles[fileName] = await fetchToMemory(fileName); } return loadedFiles[fileName]!; } + /// Loads a single [fileName] to the cache. + /// + /// Returns a decoded [String] to access that file. + Future loadPath(String fileName) async { + final encodedPath = (await load(fileName)).path; + // Web needs an url double-encoded path. + // Darwin needs a decoded path for local files. + return kIsWeb ? encodedPath : Uri.decodeFull(encodedPath); + } + /// Loads a single [fileName] to the cache but returns it as a File. /// /// Note: this is not available for web, as File doesn't make sense on the @@ -126,7 +162,14 @@ class AudioCache { throw 'This method cannot be used on web!'; } final uri = await load(fileName); - return File(uri.toFilePath()); + return fileSystem.file( + uri.toFilePath(windows: defaultTargetPlatform == TargetPlatform.windows), + ); + } + + /// Loads a single [fileName] to the cache but returns it as a list of bytes. + Future loadAsBytes(String fileName) async { + return (await loadAsFile(fileName)).readAsBytes(); } /// Loads all the [fileNames] provided to the cache. @@ -135,94 +178,4 @@ class AudioCache { Future> loadAll(List fileNames) async { return Future.wait(fileNames.map(load)); } - - AudioPlayer _player(PlayerMode mode) { - return fixedPlayer ?? AudioPlayer(mode: mode); - } - - /// Plays the given [fileName]. - /// - /// If the file is already cached, it plays immediately. Otherwise, first waits for the file to load (might take a few milliseconds). - /// It creates a new instance of [AudioPlayer], so it does not affect other audios playing (unless you specify a [fixedPlayer], in which case it always use the same). - /// The instance is returned, to allow later access (either way), like pausing and resuming. - /// - /// isNotification and stayAwake are not implemented on macOS - Future play( - String fileName, { - double volume = 1.0, - bool? isNotification, - PlayerMode mode = PlayerMode.mediaPlayer, - bool stayAwake = false, - bool recordingActive = false, - bool? duckAudio, - }) async { - final uri = await load(fileName); - final player = _player(mode); - if (fixedPlayer != null) { - await player.setReleaseMode(ReleaseMode.stop); - } - await player.play( - uri.toString(), - volume: volume, - respectSilence: isNotification ?? respectSilence, - stayAwake: stayAwake, - recordingActive: recordingActive, - duckAudio: duckAudio ?? this.duckAudio, - ); - return player; - } - - /// Plays the given [fileBytes] by a byte source. - /// - /// This is no different than calling this API via AudioPlayer, except it will return (if applicable) the cached AudioPlayer. - Future playBytes( - Uint8List fileBytes, { - double volume = 1.0, - bool? isNotification, - PlayerMode mode = PlayerMode.mediaPlayer, - bool loop = false, - bool stayAwake = false, - bool recordingActive = false, - }) async { - final player = _player(mode); - - if (loop) { - await player.setReleaseMode(ReleaseMode.loop); - } else if (fixedPlayer != null) { - await player.setReleaseMode(ReleaseMode.stop); - } - - await player.playBytes( - fileBytes, - volume: volume, - respectSilence: isNotification ?? respectSilence, - stayAwake: stayAwake, - recordingActive: recordingActive, - ); - return player; - } - - /// Like [play], but loops the audio (starts over once finished). - /// - /// The instance of [AudioPlayer] created is returned, so you can use it to stop the playback as desired. - /// - /// isNotification and stayAwake are not implemented on macOS. - Future loop( - String fileName, { - double volume = 1.0, - bool? isNotification, - PlayerMode mode = PlayerMode.mediaPlayer, - bool stayAwake = false, - }) async { - final url = await load(fileName); - final player = _player(mode); - await player.setReleaseMode(ReleaseMode.loop); - await player.play( - url.toString(), - volume: volume, - respectSilence: isNotification ?? respectSilence, - stayAwake: stayAwake, - ); - return player; - } } diff --git a/packages/audioplayers/lib/src/audio_log_level.dart b/packages/audioplayers/lib/src/audio_log_level.dart new file mode 100644 index 000000000..aa7125204 --- /dev/null +++ b/packages/audioplayers/lib/src/audio_log_level.dart @@ -0,0 +1,16 @@ +enum AudioLogLevel implements Comparable { + none(0), + error(1), + info(2); + + const AudioLogLevel(this.level); + + factory AudioLogLevel.fromInt(int level) { + return values.firstWhere((e) => e.level == level); + } + + final int level; + + @override + int compareTo(AudioLogLevel other) => level - other.level; +} diff --git a/packages/audioplayers/lib/src/audio_logger.dart b/packages/audioplayers/lib/src/audio_logger.dart new file mode 100644 index 000000000..c62941513 --- /dev/null +++ b/packages/audioplayers/lib/src/audio_logger.dart @@ -0,0 +1,46 @@ +import 'package:audioplayers/audioplayers.dart'; + +class AudioLogger { + static AudioLogLevel logLevel = AudioLogLevel.error; + + static void log(String message) { + if (AudioLogLevel.info.level <= logLevel.level) { + // ignore: avoid_print + print('AudioPlayers Log: $message'); + } + } + + static void error(Object o, [StackTrace? stacktrace]) { + if (AudioLogLevel.error.level <= logLevel.level) { + // ignore: avoid_print + print(_errorColor(errorToString(o, stacktrace))); + } + } + + static String errorToString(Object o, [StackTrace? stackTrace]) { + String errStr; + if (o is Error) { + errStr = 'AudioPlayers Error: $o\n${o.stackTrace}'; + } else if (o is Exception) { + errStr = 'AudioPlayers Exception: $o'; + } else { + errStr = 'AudioPlayers throw: $o'; + } + if (stackTrace != null && stackTrace.toString().isNotEmpty) { + errStr += '\n$stackTrace'; + } + return errStr; + } + + static String _errorColor(String text) => '\x1B[31m$text\x1B[0m'; +} + +class AudioPlayerException implements Exception { + Object? cause; + AudioPlayer player; + + AudioPlayerException(this.player, {this.cause}); + + @override + String toString() => 'AudioPlayerException(\n\t${player.source}, \n\t$cause'; +} diff --git a/packages/audioplayers/lib/src/audio_pool.dart b/packages/audioplayers/lib/src/audio_pool.dart new file mode 100644 index 000000000..1d84f26e7 --- /dev/null +++ b/packages/audioplayers/lib/src/audio_pool.dart @@ -0,0 +1,165 @@ +import 'dart:async'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:synchronized/synchronized.dart'; + +/// Represents a function that can stop an audio playing. +typedef StopFunction = Future Function(); + +/// An AudioPool is a provider of AudioPlayers that are pre-loaded with an asset +/// to minimize delays. +/// +/// All AudioPlayers are loaded with the same audio [source]. +/// If you want multiple sounds use multiple [AudioPool]s. +/// +/// Use this class if you for example have extremely quick firing, repetitive +/// or simultaneous sounds. +class AudioPool { + @visibleForTesting + final Map currentPlayers = {}; + @visibleForTesting + final List availablePlayers = []; + + /// Instance of [AudioCache] to be used by all players. + final AudioCache audioCache; + + /// Platform specific configuration. + final AudioContext? audioContext; + + /// The source of the sound for this pool. + final Source source; + + /// The minimum numbers of players, this is the amount of players that the + /// pool is initialized with. + final int minPlayers; + + /// The maximum number of players to be kept in the pool. + /// + /// If `start` is called after the pool is full there will still be new + /// [AudioPlayer]s created, but once they are stopped they will not be + /// returned to the pool. + final int maxPlayers; + + /// Whether the players in this pool use low latency mode. + final PlayerMode playerMode; + + /// Lock to synchronize access to the pool. + final Lock _lock = Lock(); + + AudioPool._({ + required this.minPlayers, + required this.maxPlayers, + required this.source, + required this.audioContext, + this.playerMode = PlayerMode.mediaPlayer, + AudioCache? audioCache, + }) : audioCache = audioCache ?? AudioCache.instance; + + /// Creates an [AudioPool] instance with the given parameters. + /// You will have to manage disposing the players if you choose + /// PlayerMode.lowLatency. + static Future create({ + required Source source, + required int maxPlayers, + AudioCache? audioCache, + AudioContext? audioContext, + int minPlayers = 1, + PlayerMode playerMode = PlayerMode.mediaPlayer, + }) async { + final instance = AudioPool._( + source: source, + audioCache: audioCache, + maxPlayers: maxPlayers, + minPlayers: minPlayers, + playerMode: playerMode, + audioContext: audioContext, + ); + + final players = []; + + for (var i = 0; i < minPlayers; i++) { + players.add(await instance._createNewAudioPlayer()); + } + + return instance..availablePlayers.addAll(players); + } + + /// Creates an [AudioPool] instance with the asset from the given [path]. + static Future createFromAsset({ + required String path, + required int maxPlayers, + AudioCache? audioCache, + int minPlayers = 1, + PlayerMode playerMode = PlayerMode.mediaPlayer, + }) async { + return create( + source: AssetSource(path), + audioCache: audioCache, + minPlayers: minPlayers, + maxPlayers: maxPlayers, + playerMode: playerMode, + ); + } + + /// Starts playing the audio, returns a function that can stop the audio. + /// You must dispose the audio player yourself if using PlayerMode.lowLatency. + Future start({double volume = 1.0}) async { + return _lock.synchronized(() async { + if (availablePlayers.isEmpty) { + availablePlayers.add(await _createNewAudioPlayer()); + } + final player = availablePlayers.removeAt(0); + currentPlayers[player.playerId] = player; + await player.setVolume(volume); + await player.resume(); + + StreamSubscription? subscription; + + Future stop() { + return _lock.synchronized(() async { + final removedPlayer = currentPlayers.remove(player.playerId); + if (removedPlayer != null) { + subscription?.cancel(); + await removedPlayer.stop(); + if (availablePlayers.length >= maxPlayers) { + await removedPlayer.release(); + } else { + availablePlayers.add(removedPlayer); + } + } + }); + } + + if (playerMode != PlayerMode.lowLatency) { + subscription = player.onPlayerComplete.listen((_) => stop()); + } + + return stop; + }); + } + + Future _createNewAudioPlayer() async { + final player = AudioPlayer()..audioCache = audioCache; + + await player.setPlayerMode(playerMode); + + if (audioContext != null) { + await player.setAudioContext(audioContext!); + } + await player.setSource(source); + await player.setReleaseMode(ReleaseMode.stop); + return player; + } + + /// Disposes the audio pool. Then it cannot be used anymore. + Future dispose() async { + // Dispose all players + await Future.wait([ + ...currentPlayers.values.map((e) => e.dispose()), + ...availablePlayers.map((e) => e.dispose()), + ]); + currentPlayers.clear(); + availablePlayers.clear(); + } +} diff --git a/packages/audioplayers/lib/src/audioplayer.dart b/packages/audioplayers/lib/src/audioplayer.dart index 29aa76ee7..94f9f8403 100644 --- a/packages/audioplayers/lib/src/audioplayer.dart +++ b/packages/audioplayers/lib/src/audioplayer.dart @@ -1,17 +1,15 @@ import 'dart:async'; import 'dart:io'; -import 'dart:typed_data'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers/src/uri_ext.dart'; +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid.dart'; -import 'api/player_mode.dart'; -import 'api/player_state.dart'; -import 'api/playing_route.dart'; -import 'api/release_mode.dart'; -import 'logger.dart'; -import 'notifications/notification_service.dart'; +const _uuid = Uuid(); /// This represents a single AudioPlayer, which can play one audio at a time. /// To play several audios at the same time, you must create several instances @@ -20,83 +18,109 @@ import 'notifications/notification_service.dart'; /// It holds methods to play, loop, pause, stop, seek the audio, and some useful /// hooks for handlers and callbacks. class AudioPlayer { - static final MethodChannel _channel = - const MethodChannel('xyz.luan/audioplayers') - ..setMethodCallHandler(platformCallHandler); + static final global = GlobalAudioScope(); + static Duration preparationTimeout = const Duration(seconds: 30); + static Duration seekingTimeout = const Duration(seconds: 30); - static const _uuid = Uuid(); + final _platform = AudioplayersPlatformInterface.instance; - final StreamController _playerStateController = - StreamController.broadcast(); + /// This is the [AudioCache] instance used by this player. + /// Unless you want to control multiple caches separately, you don't need to + /// change anything as the global instance will be used by default. + AudioCache audioCache = AudioCache.instance; - final StreamController _notificationPlayerStateController = - StreamController.broadcast(); + /// An unique ID generated for this instance of [AudioPlayer]. + /// + /// This is used to properly exchange messages with the [MethodChannel]. + final String playerId; - final StreamController _positionController = - StreamController.broadcast(); + Source? _source; - final StreamController _durationController = - StreamController.broadcast(); + Source? get source => _source; - final StreamController _completionController = - StreamController.broadcast(); + double _volume = 1.0; - final StreamController _seekCompleteController = - StreamController.broadcast(); + double get volume => _volume; - final StreamController _errorController = - StreamController.broadcast(); + double _balance = 0.0; - PlayingRoute _playingRouteState = PlayingRoute.speakers; + double get balance => _balance; - /// Reference [Map] with all the players created by the application. - /// - /// This is used to exchange messages with the [MethodChannel] - /// (because there is only one channel for all players). - static final players = {}; + double _playbackRate = 1.0; + + double get playbackRate => _playbackRate; - late NotificationService notificationService; + /// Current mode of the audio player. Can be updated at any time, but is going + /// to take effect only at the next time you play the audio. + PlayerMode _mode = PlayerMode.mediaPlayer; + + PlayerMode get mode => _mode; + + ReleaseMode _releaseMode = ReleaseMode.release; + + ReleaseMode get releaseMode => _releaseMode; + + /// Auxiliary variable to re-check the volatile player state during async + /// operations. + @visibleForTesting + PlayerState desiredState = PlayerState.stopped; PlayerState _playerState = PlayerState.stopped; PlayerState get state => _playerState; + /// The current playback state. + /// It is only set, when the corresponding action succeeds. set state(PlayerState state) { - _playerStateController.add(state); - _playerState = state; + if (_playerState == PlayerState.disposed) { + throw Exception('AudioPlayer has been disposed'); + } + if (!_playerStateController.isClosed) { + _playerStateController.add(state); + } + _playerState = desiredState = state; } - set playingRouteState(PlayingRoute routeState) { - _playingRouteState = routeState; - } + PositionUpdater? _positionUpdater; - // TODO(luan) why do we need two methods for setting state? - set notificationState(PlayerState state) { - _notificationPlayerStateController.add(state); - _playerState = state; - } + /// Completer to wait until the native player and its event stream are + /// created. + @visibleForTesting + final creatingCompleter = Completer(); + + late final StreamSubscription _onPlayerCompleteStreamSubscription; + + late final StreamSubscription _onLogStreamSubscription; + + /// Stream controller to be able to get a stream on initialization, before the + /// native event stream is ready via [_create] method. + final _eventStreamController = StreamController.broadcast(); + late final StreamSubscription _eventStreamSubscription; + + Stream get eventStream => _eventStreamController.stream; + + final StreamController _playerStateController = + StreamController.broadcast(); /// Stream of changes on player state. Stream get onPlayerStateChanged => _playerStateController.stream; - /// Stream of changes on player state coming from notification area in iOS. - Stream get onNotificationPlayerStateChanged => - _notificationPlayerStateController.stream; - /// Stream of changes on audio position. /// /// Roughly fires every 200 milliseconds. Will continuously update the /// position of the playback if the status is [PlayerState.playing]. /// /// You can use it on a progress bar, for instance. - Stream get onAudioPositionChanged => _positionController.stream; + Stream get onPositionChanged => + _positionUpdater?.positionStream ?? const Stream.empty(); /// Stream of changes on audio duration. /// /// An event is going to be sent as soon as the audio duration is available /// (it might take a while to download or buffer it). - Stream get onDurationChanged => - _durationController.stream.distinct(); + Stream get onDurationChanged => eventStream + .where((event) => event.eventType == AudioEventType.duration) + .map((event) => event.duration!); /// Stream of player completions. /// @@ -104,339 +128,364 @@ class AudioPlayer { /// sent when an audio is paused or stopped. /// /// [ReleaseMode.loop] also sends events to this stream. - Stream get onPlayerCompletion => _completionController.stream; + Stream get onPlayerComplete => + eventStream.where((event) => event.eventType == AudioEventType.complete); /// Stream of seek completions. /// /// An event is going to be sent as soon as the audio seek is finished. - Stream get onSeekComplete => _seekCompleteController.stream; + Stream get onSeekComplete => eventStream + .where((event) => event.eventType == AudioEventType.seekComplete); - /// Stream of player errors. - /// - /// Events are sent when an unexpected error is thrown in the native code. - Stream get onPlayerError => _errorController.stream; + Stream get _onPrepared => eventStream + .where((event) => event.eventType == AudioEventType.prepared) + .map((event) => event.isPrepared!); - /// An unique ID generated for this instance of [AudioPlayer]. - /// - /// This is used to properly exchange messages with the [MethodChannel]. - final String playerId; - - /// Current mode of the audio player. Can be updated at any time, but is going - /// to take effect only at the next time you play the audio. - final PlayerMode mode; + /// Stream of log events. + Stream get onLog => eventStream + .where((event) => event.eventType == AudioEventType.log) + .map((event) => event.logMessage!); /// Creates a new instance and assigns an unique id to it. - AudioPlayer({this.mode = PlayerMode.mediaPlayer, String? playerId}) - : playerId = playerId ?? _uuid.v4() { - players[this.playerId] = this; - notificationService = NotificationService(_invokeMethod); - } - - Future _invokeMethod( - String method, [ - Map arguments = const {}, - ]) { - final enhancedArgs = { - ...arguments, - 'playerId': playerId, - 'mode': mode.toString(), - }; - return invokeMethod(method, enhancedArgs); + AudioPlayer({String? playerId}) : playerId = playerId ?? _uuid.v4() { + _onLogStreamSubscription = onLog.listen( + (log) => AudioLogger.log('$log\nSource: $_source'), + onError: (Object e, [StackTrace? stackTrace]) => AudioLogger.error( + AudioPlayerException(this, cause: e), + stackTrace, + ), + ); + _onPlayerCompleteStreamSubscription = onPlayerComplete.listen( + (_) async { + state = PlayerState.completed; + if (releaseMode == ReleaseMode.release) { + _source = null; + } + await _positionUpdater?.stopAndUpdate(); + }, + onError: (Object _, [StackTrace? __]) { + /* Errors are already handled via log stream */ + }, + ); + _create(); + positionUpdater = FramePositionUpdater( + getPosition: getCurrentPosition, + ); } - static Future invokeMethod( - String method, - Map args, - ) async { - final result = await _channel.invokeMethod(method, args); - return result ?? 0; // if null, we assume error + Future _create() async { + try { + await global.ensureInitialized(); + await _platform.create(playerId); + // Assign the event stream, now that the platform registered this player. + _eventStreamSubscription = _platform.getEventStream(playerId).listen( + _eventStreamController.add, + onError: _eventStreamController.addError, + ); + creatingCompleter.complete(); + } on Exception catch (e, stackTrace) { + creatingCompleter.completeError(e, stackTrace); + } } - /// Plays an audio. + /// Play an audio [source]. /// - /// If [isLocal] is true, [url] must be a local file system path. - /// If [isLocal] is false, [url] must be a remote URL. - /// (By default isLocal is inferred by the provided path) - /// - /// respectSilence and stayAwake are not implemented on macOS. - Future play( - String url, { - bool? isLocal, - double volume = 1.0, - // position must be null by default to be compatible with radio streams + /// To reduce preparation latency, instead consider calling [setSource] + /// beforehand and then [resume] separately. + Future play( + Source source, { + double? volume, + double? balance, + AudioContext? ctx, Duration? position, - bool respectSilence = false, - bool stayAwake = false, - bool duckAudio = false, - bool recordingActive = false, + PlayerMode? mode, }) async { - final result = await _invokeMethod( - 'play', - { - 'url': url, - 'isLocal': isLocal ?? isLocalUrl(url), - 'volume': volume, - 'position': position?.inMilliseconds, - 'respectSilence': respectSilence, - 'stayAwake': stayAwake, - 'duckAudio': duckAudio, - 'recordingActive': recordingActive, - }, - ); + desiredState = PlayerState.playing; - if (result == 1) { - state = PlayerState.playing; + if (mode != null) { + await setPlayerMode(mode); + } + if (volume != null) { + await setVolume(volume); + } + if (balance != null) { + await setBalance(balance); + } + if (ctx != null) { + await setAudioContext(ctx); } - return result; - } - - /// Plays audio in the form of a byte array. - /// - /// This is only supported on Android (SDK >= 23) currently. - Future playBytes( - Uint8List bytes, { - double volume = 1.0, - // position must be null by default to be compatible with radio streams - Duration? position, - bool respectSilence = false, - bool stayAwake = false, - bool duckAudio = false, - bool recordingActive = false, - }) async { - if (!_isAndroid()) { - throw PlatformException( - code: 'Not supported', - message: 'Only Android is currently supported', - ); + await setSource(source); + if (position != null) { + await seek(position); } - final result = await _invokeMethod( - 'playBytes', - { - 'bytes': bytes, - 'volume': volume, - 'position': position?.inMilliseconds, - 'respectSilence': respectSilence, - 'stayAwake': stayAwake, - 'duckAudio': duckAudio, - 'recordingActive': recordingActive, - }, - ); + await _resume(); + } - if (result == 1) { - state = PlayerState.playing; - } + Future setAudioContext(AudioContext ctx) async { + await creatingCompleter.future; + return _platform.setAudioContext(playerId, ctx); + } - return result; + Future setPlayerMode(PlayerMode mode) async { + _mode = mode; + await creatingCompleter.future; + return _platform.setPlayerMode(playerId, mode); } /// Pauses the audio that is currently playing. /// /// If you call [resume] later, the audio will resume from the point that it /// has been paused. - Future pause() async { - final result = await _invokeMethod('pause'); - - if (result == 1) { + Future pause() async { + desiredState = PlayerState.paused; + await creatingCompleter.future; + if (desiredState == PlayerState.paused) { + await _platform.pause(playerId); state = PlayerState.paused; + await _positionUpdater?.stopAndUpdate(); } - - return result; } /// Stops the audio that is currently playing. /// /// The position is going to be reset and you will no longer be able to resume /// from the last point. - Future stop() async { - final result = await _invokeMethod('stop'); - - if (result == 1) { + Future stop() async { + desiredState = PlayerState.stopped; + await creatingCompleter.future; + if (desiredState == PlayerState.stopped) { + await _platform.stop(playerId); state = PlayerState.stopped; + await _positionUpdater?.stopAndUpdate(); } - - return result; } - /// Resumes the audio that has been paused or stopped, just like calling - /// [play], but without changing the parameters. - Future resume() async { - final result = await _invokeMethod('resume'); + /// Resumes the audio that has been paused or stopped. + Future resume() async { + desiredState = PlayerState.playing; + await _resume(); + } - if (result == 1) { + /// Resume without setting the desired state. + Future _resume() async { + await creatingCompleter.future; + if (desiredState == PlayerState.playing) { + await _platform.resume(playerId); state = PlayerState.playing; + _positionUpdater?.start(); } - - return result; } /// Releases the resources associated with this media player. /// /// The resources are going to be fetched or buffered again as soon as you - /// call [play] or [setUrl]. - Future release() async { - final result = await _invokeMethod('release'); + /// call [resume] or change the source. + Future release() async { + await stop(); + await _platform.release(playerId); + // Stop state already set in stop() + _source = null; + } - if (result == 1) { - state = PlayerState.stopped; - } + /// Moves the cursor to the desired position. + Future seek(Duration position) async { + await creatingCompleter.future; + + final futureSeekComplete = + onSeekComplete.first.timeout(AudioPlayer.seekingTimeout); + final futureSeek = _platform.seek(playerId, position); + // Wait simultaneously to ensure all errors are propagated through the same + // future. + await Future.wait([futureSeek, futureSeekComplete]); - return result; + await _positionUpdater?.update(); } - /// Moves the cursor to the desired position. - Future seek(Duration position) { - _positionController.add(position); - return _invokeMethod( - 'seek', - { - 'position': position.inMilliseconds, - }, - ); + /// Sets the stereo balance. + /// + /// -1 - The left channel is at full volume; the right channel is silent. + /// 1 - The right channel is at full volume; the left channel is silent. + /// 0 - Both channels are at the same volume. + Future setBalance(double balance) async { + _balance = balance; + await creatingCompleter.future; + return _platform.setBalance(playerId, balance); } /// Sets the volume (amplitude). /// /// 0 is mute and 1 is the max volume. The values between 0 and 1 are linearly /// interpolated. - Future setVolume(double volume) { - return _invokeMethod( - 'setVolume', - { - 'volume': volume, - }, - ); + Future setVolume(double volume) async { + _volume = volume; + await creatingCompleter.future; + return _platform.setVolume(playerId, volume); } /// Sets the release mode. /// /// Check [ReleaseMode]'s doc to understand the difference between the modes. - Future setReleaseMode(ReleaseMode releaseMode) { - return _invokeMethod( - 'setReleaseMode', - { - 'releaseMode': releaseMode.toString(), - }, - ); + Future setReleaseMode(ReleaseMode releaseMode) async { + _releaseMode = releaseMode; + await creatingCompleter.future; + return _platform.setReleaseMode(playerId, releaseMode); } /// Sets the playback rate - call this after first calling play() or resume(). /// /// iOS and macOS have limits between 0.5 and 2x /// Android SDK version should be 23 or higher - Future setPlaybackRate(double playbackRate) { - return _invokeMethod( - 'setPlaybackRate', - { - 'playbackRate': playbackRate, - }, - ); + Future setPlaybackRate(double playbackRate) async { + _playbackRate = playbackRate; + await creatingCompleter.future; + return _platform.setPlaybackRate(playerId, playbackRate); } - /// Sets the URL. + /// Sets the audio source for this player. /// - /// Unlike [play], the playback will not resume. + /// This will delegate to one of the specific methods below depending on + /// the source type. + Future setSource(Source source) async { + // Implementations of setOnPlayer also call `creatingCompleter.future` + await source.setOnPlayer(this); + } + + /// This method helps waiting for a source to be set until it's prepared. + /// This can happen immediately after [setSource] has finished or it needs to + /// wait for the [AudioEvent] [AudioEventType.prepared] to arrive. + Future _completePrepared(Future Function() setSource) async { + await creatingCompleter.future; + + final preparedFuture = _onPrepared + .firstWhere((isPrepared) => isPrepared) + .timeout(AudioPlayer.preparationTimeout); + // Need to await the setting the source to propagate immediate errors. + final setSourceFuture = setSource(); + + // Wait simultaneously to ensure all errors are propagated through the same + // future. + await Future.wait([setSourceFuture, preparedFuture]); + + // Share position once after finished loading + await _positionUpdater?.update(); + } + + /// Sets the URL to a remote link. /// /// The resources will start being fetched or buffered as soon as you call /// this method. - /// - /// respectSilence is not implemented on macOS. - Future setUrl( - String url, { - bool? isLocal, - bool respectSilence = false, - bool recordingActive = false, - }) { - return _invokeMethod( - 'setUrl', - { - 'url': url, - 'isLocal': isLocal ?? isLocalUrl(url), - 'respectSilence': respectSilence, - 'recordingActive': recordingActive, - }, + Future setSourceUrl(String url, {String? mimeType}) async { + if (!kIsWeb && + defaultTargetPlatform != TargetPlatform.android && + url.startsWith('data:')) { + // Convert data URI's to bytes (native support for web and android). + final uriData = UriData.fromUri(Uri.parse(url)); + mimeType ??= url.substring(url.indexOf(':') + 1, url.indexOf(';')); + await setSourceBytes(uriData.contentAsBytes(), mimeType: mimeType); + return; + } + + _source = UrlSource(url, mimeType: mimeType); + // Encode remote url to avoid unexpected failures. + await _completePrepared( + () => _platform.setSourceUrl( + playerId, + UriCoder.encodeOnce(url), + mimeType: mimeType, + isLocal: false, + ), ); } - /// Get audio duration after setting url. - /// Use it in conjunction with setUrl. + /// Sets the URL to a file in the users device. /// - /// It will be available as soon as the audio duration is available - /// (it might take a while to download or buffer it if file is not local). - Future getDuration() { - return _invokeMethod('getDuration'); + /// The resources will start being fetched or buffered as soon as you call + /// this method. + Future setSourceDeviceFile(String path, {String? mimeType}) async { + _source = DeviceFileSource(path, mimeType: mimeType); + await _completePrepared( + () => _platform.setSourceUrl( + playerId, + path, + isLocal: true, + mimeType: mimeType, + ), + ); } - // Gets audio current playing position - Future getCurrentPosition() async { - return _invokeMethod('getCurrentPosition'); + /// Sets the URL to an asset in your Flutter application. + /// The global instance of AudioCache will be used by default. + /// + /// The resources will start being fetched or buffered as soon as you call + /// this method. + Future setSourceAsset(String path, {String? mimeType}) async { + _source = AssetSource(path, mimeType: mimeType); + final cachePath = await audioCache.loadPath(path); + await _completePrepared( + () => _platform.setSourceUrl( + playerId, + cachePath, + mimeType: mimeType, + isLocal: true, + ), + ); } - static Future platformCallHandler(MethodCall call) async { - try { - _doHandlePlatformCall(call); - } catch (ex) { - Logger.error('Unexpected error: $ex'); + Future setSourceBytes(Uint8List bytes, {String? mimeType}) async { + if (!kIsWeb && + (defaultTargetPlatform == TargetPlatform.iOS || + defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.linux)) { + // Convert to file as workaround + final tempDir = (await getTemporaryDirectory()).path; + final bytesHash = Object.hashAll(bytes) + .toUnsigned(20) + .toRadixString(16) + .padLeft(5, '0'); + final file = File('$tempDir/$bytesHash'); + await file.writeAsBytes(bytes); + await setSourceDeviceFile(file.path, mimeType: mimeType); + } else { + _source = BytesSource(bytes, mimeType: mimeType); + await _completePrepared( + () => _platform.setSourceBytes(playerId, bytes, mimeType: mimeType), + ); } } - static Future _doHandlePlatformCall(MethodCall call) async { - final callArgs = call.arguments as Map; - Logger.info('_platformCallHandler call ${call.method} $callArgs'); - - final playerId = callArgs['playerId'] as String; - final player = players[playerId]; + /// Set the PositionUpdater to control how often the position stream will be + /// updated. You can use the [FramePositionUpdater], the + /// [TimerPositionUpdater] or write your own implementation of the + /// [PositionUpdater]. + set positionUpdater(PositionUpdater? positionUpdater) { + _positionUpdater?.dispose(); // No need to wait for dispose + _positionUpdater = positionUpdater; + } - if (!kReleaseMode && _isAndroid() && player == null) { - final oldPlayer = AudioPlayer(playerId: playerId); - await oldPlayer.release(); - oldPlayer.dispose(); - players.remove(playerId); - return; - } - if (player == null) { - return; + /// Get audio duration after setting url. + /// Use it in conjunction with setUrl. + /// + /// It will be available as soon as the audio duration is available + /// (it might take a while to download or buffer it if file is not local). + Future getDuration() async { + await creatingCompleter.future; + final milliseconds = await _platform.getDuration(playerId); + if (milliseconds == null) { + return null; } + return Duration(milliseconds: milliseconds); + } - switch (call.method) { - case 'audio.onNotificationPlayerStateChanged': - final isPlaying = callArgs['value'] as bool; - player.notificationState = - isPlaying ? PlayerState.playing : PlayerState.paused; - break; - case 'audio.onDuration': - final millis = callArgs['value'] as int; - final newDuration = Duration(milliseconds: millis); - player._durationController.add(newDuration); - break; - case 'audio.onCurrentPosition': - final millis = callArgs['value'] as int; - final newDuration = Duration(milliseconds: millis); - player._positionController.add(newDuration); - break; - case 'audio.onComplete': - player.state = PlayerState.completed; - player._completionController.add(null); - break; - case 'audio.onSeekComplete': - final complete = callArgs['value'] as bool; - player._seekCompleteController.add(complete); - break; - case 'audio.onError': - final error = callArgs['value'] as String; - player.state = PlayerState.stopped; - player._errorController.add(error); - break; - case 'audio.onGotNextTrackCommand': - player.notificationService.notifyNextTrack(); - break; - case 'audio.onGotPreviousTrackCommand': - player.notificationService.notifyPreviousTrack(); - break; - default: - Logger.error('Unknown method ${call.method} '); + // Gets audio current playing position + Future getCurrentPosition() async { + await creatingCompleter.future; + final milliseconds = await _platform.getCurrentPosition(playerId); + if (milliseconds == null) { + return null; } + return Duration(milliseconds: milliseconds); } /// Closes all [StreamController]s. @@ -447,62 +496,22 @@ class AudioPlayer { // First stop and release all native resources. await release(); - final futures = []; + state = desiredState = PlayerState.disposed; - if (!_playerStateController.isClosed) { - futures.add(_playerStateController.close()); - } - if (!_notificationPlayerStateController.isClosed) { - futures.add(_notificationPlayerStateController.close()); - } - if (!_positionController.isClosed) { - futures.add(_positionController.close()); - } - if (!_durationController.isClosed) { - futures.add(_durationController.close()); - } - if (!_completionController.isClosed) { - futures.add(_completionController.close()); - } - if (!_seekCompleteController.isClosed) { - futures.add(_seekCompleteController.close()); - } - if (!_errorController.isClosed) { - futures.add(_errorController.close()); - } - futures.add(notificationService.dispose()); + final futures = [ + if (_positionUpdater != null) _positionUpdater!.dispose(), + if (!_playerStateController.isClosed) _playerStateController.close(), + _onPlayerCompleteStreamSubscription.cancel(), + _onLogStreamSubscription.cancel(), + _eventStreamSubscription.cancel(), + _eventStreamController.close(), + ]; - await Future.wait(futures); - players.remove(playerId); - } + _source = null; - Future earpieceOrSpeakersToggle() async { - final playingRoute = _playingRouteState.toggle(); - final result = await _invokeMethod( - 'earpieceOrSpeakersToggle', - { - 'playingRoute': playingRoute.name(), - }, - ); - - if (result == 1) { - playingRouteState = playingRoute; - } - - return result; - } - - bool isLocalUrl(String url) { - return url.startsWith('/') || - url.startsWith('file://') || - url.substring(1).startsWith(':\\'); - } + await Future.wait(futures); - static bool _isAndroid() { - // we need to be careful because the "isAndroid" check throws errors on web. - if (kIsWeb) { - return false; - } - return Platform.isAndroid; + // Needs to be called after cancelling event stream subscription: + await _platform.dispose(playerId); } } diff --git a/packages/audioplayers/lib/src/audioplayers_web.dart b/packages/audioplayers/lib/src/audioplayers_web.dart deleted file mode 100644 index ec8a2d9fd..000000000 --- a/packages/audioplayers/lib/src/audioplayers_web.dart +++ /dev/null @@ -1,259 +0,0 @@ -import 'dart:async'; -import 'dart:html'; - -import 'package:flutter/services.dart'; -import 'package:flutter_web_plugins/flutter_web_plugins.dart'; - -import 'api/release_mode.dart'; - -class WrappedPlayer { - final String playerId; - final AudioplayersPlugin plugin; - - double? pausedAt; - double currentVolume = 1.0; - double currentPlaybackRate = 1.0; - ReleaseMode currentReleaseMode = ReleaseMode.release; - String? currentUrl; - bool isPlaying = false; - - AudioElement? player; - StreamSubscription? playerTimeUpdateSubscription; - - WrappedPlayer(this.plugin, this.playerId); - - void setUrl(String url) { - currentUrl = url; - - stop(); - recreateNode(); - if (isPlaying) { - resume(); - } - } - - void setVolume(double volume) { - currentVolume = volume; - player?.volume = volume; - } - - void setPlaybackRate(double rate) { - currentPlaybackRate = rate; - player?.playbackRate = rate; - } - - void recreateNode() { - if (currentUrl == null) { - return; - } - player = AudioElement(currentUrl); - player?.loop = shouldLoop(); - player?.volume = currentVolume; - player?.playbackRate = currentPlaybackRate; - playerTimeUpdateSubscription = player?.onTimeUpdate.listen( - (_) => plugin.channel.invokeMethod( - 'audio.onCurrentPosition', - { - 'playerId': playerId, - 'value': (1000 * (player?.currentTime ?? 0)).round(), - }, - ), - ); - } - - bool shouldLoop() => currentReleaseMode == ReleaseMode.loop; - - void setReleaseMode(ReleaseMode releaseMode) { - currentReleaseMode = releaseMode; - player?.loop = shouldLoop(); - } - - void release() { - _cancel(); - player = null; - - playerTimeUpdateSubscription?.cancel(); - playerTimeUpdateSubscription = null; - } - - void start(double position) { - isPlaying = true; - if (currentUrl == null) { - return; // nothing to play yet - } - if (player == null) { - recreateNode(); - } - player?.play(); - player?.currentTime = position; - } - - void resume() { - start(pausedAt ?? 0); - } - - void pause() { - pausedAt = player?.currentTime as double?; - _cancel(); - } - - void stop() { - pausedAt = 0; - _cancel(); - } - - void seek(int position) { - player?.currentTime = position / 1000.0; - } - - void _cancel() { - isPlaying = false; - player?.pause(); - if (currentReleaseMode == ReleaseMode.release) { - player = null; - } - } -} - -class AudioplayersPlugin { - final MethodChannel channel; - - // players by playerId - Map players = {}; - - AudioplayersPlugin(this.channel); - - static void registerWith(Registrar registrar) { - final channel = MethodChannel( - 'xyz.luan/audioplayers', - const StandardMethodCodec(), - registrar, - ); - - final instance = AudioplayersPlugin(channel); - channel.setMethodCallHandler(instance.handleMethodCall); - } - - WrappedPlayer getOrCreatePlayer(String playerId) { - return players.putIfAbsent(playerId, () => WrappedPlayer(this, playerId)); - } - - Future setUrl(String playerId, String url) async { - final player = getOrCreatePlayer(playerId); - - if (player.currentUrl == url) { - return player; - } - - player.setUrl(url); - return player; - } - - ReleaseMode parseReleaseMode(String value) { - return ReleaseMode.values.firstWhere((e) => e.toString() == value); - } - - Future handleMethodCall(MethodCall call) async { - final method = call.method; - switch (method) { - case 'changeLogLevel': - { - // no-op for now - return 1; - } - } - - final args = call.arguments as Map; - final playerId = args['playerId'] as String; - switch (method) { - case 'setUrl': - { - final url = args['url'] as String; - await setUrl(playerId, url); - return 1; - } - case 'play': - { - final url = args['url'] as String; - - // TODO(luan) think about isLocal (is it needed or not) - - final volume = args['volume'] as double? ?? 1.0; - final position = args['position'] as double? ?? 0; - // web does not care for the `stayAwake` argument - - final player = await setUrl(playerId, url); - player.setVolume(volume); - player.start(position); - - return 1; - } - case 'getCurrentPosition': - { - final position = getOrCreatePlayer(playerId).player?.currentTime; - if (position == null) { - return null; - } - return (position * 1000).toInt(); - } - case 'getDuration': - { - final duration = getOrCreatePlayer(playerId).player?.duration; - if (duration == null) { - return null; - } - return (duration * 1000).toInt(); - } - case 'pause': - { - getOrCreatePlayer(playerId).pause(); - return 1; - } - case 'stop': - { - getOrCreatePlayer(playerId).stop(); - return 1; - } - case 'resume': - { - getOrCreatePlayer(playerId).resume(); - return 1; - } - case 'setVolume': - { - final volume = args['volume'] as double? ?? 1.0; - getOrCreatePlayer(playerId).setVolume(volume); - return 1; - } - case 'setReleaseMode': - { - final releaseMode = parseReleaseMode(args['releaseMode'] as String); - getOrCreatePlayer(playerId).setReleaseMode(releaseMode); - return 1; - } - case 'release': - { - getOrCreatePlayer(playerId).release(); - return 1; - } - case 'setPlaybackRate': - { - final rate = args['playbackRate'] as double? ?? 1.0; - getOrCreatePlayer(playerId).setPlaybackRate(rate); - return 1; - } - case 'seek': - { - final position = args['position'] as int? ?? 0; - getOrCreatePlayer(playerId).seek(position); - return 1; - } - default: - throw PlatformException( - code: 'Unimplemented', - details: - "The audioplayers plugin for web doesn't implement the method '$method'", - ); - } - } -} diff --git a/packages/audioplayers/lib/src/global_audio_scope.dart b/packages/audioplayers/lib/src/global_audio_scope.dart new file mode 100644 index 000000000..8aeb71463 --- /dev/null +++ b/packages/audioplayers/lib/src/global_audio_scope.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:audioplayers/src/audio_logger.dart'; +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; + +GlobalAudioplayersPlatformInterface? _lastGlobalAudioplayersPlatform; + +/// Handle global audio scope like calls and events concerning all AudioPlayers. +class GlobalAudioScope { + Completer? _initCompleter; + + GlobalAudioplayersPlatformInterface get _platform => + GlobalAudioplayersPlatformInterface.instance; + + /// Stream of global events. + late final Stream eventStream; + + /// Stream of global log events. + Stream get onLog => eventStream + .where((event) => event.eventType == GlobalAudioEventType.log) + .map((event) => event.logMessage!); + + GlobalAudioScope() { + eventStream = _platform.getGlobalEventStream(); + onLog.listen( + AudioLogger.log, + onError: AudioLogger.error, + ); + } + + /// Ensure the global platform is initialized. + Future ensureInitialized() async { + if (_lastGlobalAudioplayersPlatform != _platform) { + // This will clear all open players on the platform when a full restart is + // performed. + _lastGlobalAudioplayersPlatform = _platform; + _initCompleter = Completer(); + try { + await _platform.init(); + _initCompleter?.complete(); + } on Exception catch (e, stackTrace) { + _initCompleter?.completeError(e, stackTrace); + } + } + await _initCompleter?.future; + } + + Future setAudioContext(AudioContext ctx) async { + await ensureInitialized(); + await _platform.setGlobalAudioContext(ctx); + } +} diff --git a/packages/audioplayers/lib/src/logger.dart b/packages/audioplayers/lib/src/logger.dart deleted file mode 100644 index 57cb39b36..000000000 --- a/packages/audioplayers/lib/src/logger.dart +++ /dev/null @@ -1,29 +0,0 @@ -import '../audioplayers.dart'; - -class Logger { - static LogLevel _logLevel = LogLevel.error; - - static LogLevel get logLevel => _logLevel; - - static Future changeLogLevel(LogLevel value) { - _logLevel = value; - return AudioPlayer.invokeMethod( - 'changeLogLevel', - {'value': value.toString()}, - ); - } - - Logger._() { - throw UnimplementedError(); - } - - static void log(LogLevel level, String message) { - if (level.getLevel() <= logLevel.getLevel()) { - print(message); - } - } - - static void info(String message) => log(LogLevel.info, message); - - static void error(String message) => log(LogLevel.error, message); -} diff --git a/packages/audioplayers/lib/src/notifications/notification_service.dart b/packages/audioplayers/lib/src/notifications/notification_service.dart deleted file mode 100644 index 388f98421..000000000 --- a/packages/audioplayers/lib/src/notifications/notification_service.dart +++ /dev/null @@ -1,194 +0,0 @@ -import 'dart:async'; -import 'dart:ui'; - -import 'package:flutter/foundation.dart' show defaultTargetPlatform; -import 'package:flutter/services.dart'; -import 'package:flutter/widgets.dart'; - -import '../api/player_state.dart'; -import 'player_control_command.dart'; - -/// Note: this is an iOS only feature (so far). Does not work with android/ -/// web/macOS. -/// -/// This bundles together all the notification related features from AP. -class NotificationService { - /// Enable the notifications feature. This is a global toggle. - /// - /// Note: for best effects if you want to disable this, do it at the start - /// of your app. - /// TODO(luan) consider making this false by default. - static bool enableNotificationService = true; - - final Future Function( - String, - Map, - ) platformChannelInvoke; - - NotificationService(this.platformChannelInvoke) { - if (enableNotificationService) { - startHeadlessService(); - } - } - - final StreamController _commandController = - StreamController.broadcast(); - - /// Stream of remote player command sent by native side - /// - /// Events are sent when the user taps the system control commands on the - /// notification page. - // TODO(luan) improve communication with the notification widget - Stream get onPlayerCommand => _commandController.stream; - - /// This should be called after initiating AudioPlayer only if you want to - /// listen for notification changes in the background. - /// - /// Only for iOS (not implemented on macOS, android, web) - Future startHeadlessService() async { - return _callWithHandle( - 'startHeadlessService', - _backgroundCallbackDispatcher, - ); - } - - /// Start getting significant audio updates through `callback`. - /// - /// `callback` is invoked on a background isolate and will not have direct - /// access to the state held by the main isolate (or any other isolate). - Future monitorStateChanges( - void Function(PlayerState value) callback, - ) async { - return _callWithHandle('monitorNotificationStateChanges', callback); - } - - /// Sets the notification bar for lock screen and notification area in iOS for now. - /// - /// At least the [title] is required. - Future setNotification({ - String title = '', - String albumTitle = '', - String artist = '', - String imageUrl = '', - Duration forwardSkipInterval = Duration.zero, - Duration backwardSkipInterval = Duration.zero, - Duration duration = Duration.zero, - Duration elapsedTime = Duration.zero, - bool enablePreviousTrackButton = false, - bool enableNextTrackButton = false, - }) async { - return _call( - 'setNotification', - { - 'title': title, - 'albumTitle': albumTitle, - 'artist': artist, - 'imageUrl': imageUrl, - 'forwardSkipInterval': forwardSkipInterval.inSeconds, - 'backwardSkipInterval': backwardSkipInterval.inSeconds, - 'duration': duration.inSeconds, - 'elapsedTime': elapsedTime.inSeconds, - 'enablePreviousTrackButton': enablePreviousTrackButton, - 'enableNextTrackButton': enableNextTrackButton, - }, - ); - } - - Future clearNotification() { - return _call('clearNotification', {}); - } - - Future _callWithHandle(String methodName, Function callback) async { - if (!enableNotificationService) { - throw 'The notifications feature was disabled.'; - } - if (defaultTargetPlatform != TargetPlatform.iOS) { - return; - } - await platformChannelInvoke( - methodName, - { - 'handleKey': _getBgHandleKey(callback), - }, - ); - } - - Future _call(String methodName, Map args) async { - if (!enableNotificationService) { - throw 'The notifications feature was disabled.'; - } - if (defaultTargetPlatform != TargetPlatform.iOS) { - return; - } - await platformChannelInvoke(methodName, args); - } - - Future dispose() async { - if (!_commandController.isClosed) { - await _commandController.close(); - } - } - - void notifyNextTrack() { - _commandController.add(PlayerControlCommand.nextTrack); - } - - void notifyPreviousTrack() { - _commandController.add(PlayerControlCommand.previousTrack); - } -} - -List _getBgHandleKey(Function callback) { - final handle = PluginUtilities.getCallbackHandle(callback); - assert(handle != null, 'Unable to lookup callback.'); - return [handle!.toRawHandle()]; -} - -/// When we start the background service isolate, we only ever enter it once. -/// To communicate between the native plugin and this entrypoint, we'll use -/// MethodChannels to open a persistent communication channel to trigger -/// callbacks. -void _backgroundCallbackDispatcher() { - const _channel = MethodChannel('xyz.luan/audioplayers_callback'); - - // Setup Flutter state needed for MethodChannels. - WidgetsFlutterBinding.ensureInitialized(); - - // Reference to the onAudioChangeBackgroundEvent callback. - Function(PlayerState)? onAudioChangeBackgroundEvent; - - // This is where the magic happens and we handle background events from the - // native portion of the plugin. Here we message the audio notification data - // which we then pass to the provided callback. - _channel.setMethodCallHandler((MethodCall call) async { - final args = call.arguments as Map; - Function(PlayerState) _performCallbackLookup() { - final handle = CallbackHandle.fromRawHandle( - args['updateHandleMonitorKey'] as int, - ); - - // PluginUtilities.getCallbackFromHandle performs a lookup based on the - // handle we retrieved earlier. - final closure = PluginUtilities.getCallbackFromHandle(handle); - - if (closure == null) { - throw 'Fatal Error: Callback lookup failed!'; - } - return closure as Function(PlayerState); - } - - if (call.method == 'audio.onNotificationBackgroundPlayerStateChanged') { - onAudioChangeBackgroundEvent ??= _performCallbackLookup(); - final playerState = args['value'] as String; - if (playerState == 'playing') { - onAudioChangeBackgroundEvent!(PlayerState.playing); - } else if (playerState == 'paused') { - onAudioChangeBackgroundEvent!(PlayerState.paused); - } else if (playerState == 'completed') { - onAudioChangeBackgroundEvent!(PlayerState.completed); - } - } else { - assert(false, "No handler defined for method type: '${call.method}'"); - } - }); -} diff --git a/packages/audioplayers/lib/src/notifications/player_control_command.dart b/packages/audioplayers/lib/src/notifications/player_control_command.dart deleted file mode 100644 index 10ad5af25..000000000 --- a/packages/audioplayers/lib/src/notifications/player_control_command.dart +++ /dev/null @@ -1,4 +0,0 @@ -enum PlayerControlCommand { - nextTrack, - previousTrack, -} diff --git a/packages/audioplayers/lib/src/position_updater.dart b/packages/audioplayers/lib/src/position_updater.dart new file mode 100644 index 000000000..d3a57713f --- /dev/null +++ b/packages/audioplayers/lib/src/position_updater.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter/scheduler.dart'; + +abstract class PositionUpdater { + /// You can use `player.getCurrentPosition` as the [getPosition] parameter. + PositionUpdater({ + required this.getPosition, + }); + + final Future Function() getPosition; + final _streamController = StreamController.broadcast(); + + Stream get positionStream => _streamController.stream; + + Future update() async { + final position = await getPosition(); + if (position != null) { + _streamController.add(position); + } + } + + void start(); + + void stop(); + + Future stopAndUpdate() async { + stop(); + await update(); + } + + Future dispose() async { + stop(); + await _streamController.close(); + } +} + +class TimerPositionUpdater extends PositionUpdater { + Timer? _positionStreamTimer; + final Duration interval; + + /// Position stream will be updated in the according [interval]. + TimerPositionUpdater({ + required super.getPosition, + required this.interval, + }); + + @override + void start() { + _positionStreamTimer?.cancel(); + _positionStreamTimer = Timer.periodic(interval, (timer) async { + await update(); + }); + } + + @override + void stop() { + _positionStreamTimer?.cancel(); + _positionStreamTimer = null; + } +} + +class FramePositionUpdater extends PositionUpdater { + int? _frameCallbackId; + bool _isRunning = false; + + /// Position stream will be updated at every new frame. + FramePositionUpdater({ + required super.getPosition, + }); + + void _tick(Duration? timestamp) { + if (_isRunning) { + update(); + _frameCallbackId = SchedulerBinding.instance.scheduleFrameCallback(_tick); + } + } + + @override + void start() { + _isRunning = true; + _tick(null); + } + + @override + void stop() { + _isRunning = false; + if (_frameCallbackId != null) { + SchedulerBinding.instance.cancelFrameCallbackWithId(_frameCallbackId!); + } + } +} diff --git a/packages/audioplayers/lib/src/source.dart b/packages/audioplayers/lib/src/source.dart new file mode 100644 index 000000000..7cdb81180 --- /dev/null +++ b/packages/audioplayers/lib/src/source.dart @@ -0,0 +1,100 @@ +import 'dart:math'; + +import 'package:audioplayers/src/audioplayer.dart'; +import 'package:flutter/foundation.dart'; + +/// A generic representation of a source from where audio can be pulled. +/// +/// This can be a remote or local URL, an application asset, or the file bytes. +abstract class Source { + String? get mimeType; + + Future setOnPlayer(AudioPlayer player); +} + +/// Source representing a remote URL to be played from the Internet. +/// This can be an audio file to be downloaded or an audio stream. +class UrlSource extends Source { + final String url; + + @override + final String? mimeType; + + UrlSource(this.url, {this.mimeType}); + + @override + Future setOnPlayer(AudioPlayer player) { + return player.setSourceUrl(url, mimeType: mimeType); + } + + @override + String toString() { + return 'UrlSource(url: ${url.substring(0, min(500, url.length))},' + ' mimeType: $mimeType)'; + } +} + +/// Source representing the absolute path of a file in the user's device. +class DeviceFileSource extends Source { + final String path; + + @override + final String? mimeType; + + DeviceFileSource(this.path, {this.mimeType}); + + @override + Future setOnPlayer(AudioPlayer player) { + return player.setSourceDeviceFile(path, mimeType: mimeType); + } + + @override + String toString() { + return 'DeviceFileSource(path: $path, mimeType: $mimeType)'; + } +} + +/// Source representing the path of an application asset in your Flutter +/// "assets" folder. +/// Note that a prefix might be applied by your [AudioPlayer]'s audio cache +/// instance. +class AssetSource extends Source { + final String path; + + @override + final String? mimeType; + + AssetSource(this.path, {this.mimeType}); + + @override + Future setOnPlayer(AudioPlayer player) { + return player.setSourceAsset(path, mimeType: mimeType); + } + + @override + String toString() { + return 'AssetSource(path: $path, mimeType: $mimeType)'; + } +} + +/// Source containing the actual bytes of the media to be played. +class BytesSource extends Source { + final Uint8List bytes; + + @override + final String? mimeType; + + BytesSource(this.bytes, {this.mimeType}); + + @override + Future setOnPlayer(AudioPlayer player) { + return player.setSourceBytes(bytes, mimeType: mimeType); + } + + @override + String toString() { + final bytesHash = + Object.hashAll(bytes).toUnsigned(20).toRadixString(16).padLeft(5, '0'); + return 'BytesSource(bytes: $bytesHash, mimeType: $mimeType)'; + } +} diff --git a/packages/audioplayers/lib/src/uri_ext.dart b/packages/audioplayers/lib/src/uri_ext.dart new file mode 100644 index 000000000..80d6fccc2 --- /dev/null +++ b/packages/audioplayers/lib/src/uri_ext.dart @@ -0,0 +1,12 @@ +extension UriCoder on Uri { + static String encodeOnce(String uri) { + try { + // If decoded differs, the uri was already encoded. + final decodedUri = Uri.decodeFull(uri); + if (decodedUri != uri) { + return uri; + } + } on ArgumentError catch (_) {} + return Uri.encodeFull(uri); + } +} diff --git a/packages/audioplayers/lib/web/audioplayers_web.dart b/packages/audioplayers/lib/web/audioplayers_web.dart deleted file mode 100644 index 81cf15b1e..000000000 --- a/packages/audioplayers/lib/web/audioplayers_web.dart +++ /dev/null @@ -1 +0,0 @@ -export '../src/audioplayers_web.dart'; diff --git a/packages/audioplayers/macos/Classes/AudioplayersPlugin.h b/packages/audioplayers/macos/Classes/AudioplayersPlugin.h deleted file mode 120000 index 74e276007..000000000 --- a/packages/audioplayers/macos/Classes/AudioplayersPlugin.h +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/AudioplayersPlugin.h \ No newline at end of file diff --git a/packages/audioplayers/macos/Classes/AudioplayersPlugin.m b/packages/audioplayers/macos/Classes/AudioplayersPlugin.m deleted file mode 120000 index c94fef942..000000000 --- a/packages/audioplayers/macos/Classes/AudioplayersPlugin.m +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/AudioplayersPlugin.m \ No newline at end of file diff --git a/packages/audioplayers/macos/Classes/Logger.swift b/packages/audioplayers/macos/Classes/Logger.swift deleted file mode 120000 index a8df5c764..000000000 --- a/packages/audioplayers/macos/Classes/Logger.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/Logger.swift \ No newline at end of file diff --git a/packages/audioplayers/macos/Classes/NotificationsHandler.swift b/packages/audioplayers/macos/Classes/NotificationsHandler.swift deleted file mode 120000 index e99210244..000000000 --- a/packages/audioplayers/macos/Classes/NotificationsHandler.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/NotificationsHandler.swift \ No newline at end of file diff --git a/packages/audioplayers/macos/Classes/SwiftAudioplayersPlugin.swift b/packages/audioplayers/macos/Classes/SwiftAudioplayersPlugin.swift deleted file mode 120000 index f07fc31c9..000000000 --- a/packages/audioplayers/macos/Classes/SwiftAudioplayersPlugin.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/SwiftAudioplayersPlugin.swift \ No newline at end of file diff --git a/packages/audioplayers/macos/Classes/Utils.swift b/packages/audioplayers/macos/Classes/Utils.swift deleted file mode 120000 index dd1f0c079..000000000 --- a/packages/audioplayers/macos/Classes/Utils.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/Utils.swift \ No newline at end of file diff --git a/packages/audioplayers/macos/Classes/WrappedMediaPlayer.swift b/packages/audioplayers/macos/Classes/WrappedMediaPlayer.swift deleted file mode 120000 index 3adc9cd87..000000000 --- a/packages/audioplayers/macos/Classes/WrappedMediaPlayer.swift +++ /dev/null @@ -1 +0,0 @@ -../../darwin/Classes/WrappedMediaPlayer.swift \ No newline at end of file diff --git a/packages/audioplayers/macos/audioplayers.podspec b/packages/audioplayers/macos/audioplayers.podspec deleted file mode 100644 index 42e50c680..000000000 --- a/packages/audioplayers/macos/audioplayers.podspec +++ /dev/null @@ -1,24 +0,0 @@ -# -# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. -# Run `pod lib lint audioplayers.podspec' to validate before publishing. -# -Pod::Spec.new do |s| - s.name = 'audioplayers' - s.version = '0.0.1' - s.summary = 'A flutter plugin to play multiple simultaneously audio files.' - s.description = <<-DESC - A flutter plugin to play multiple simultaneously audio files. - - This is a fork of rxlabz's audioplayer, with the difference that it supports playing multiple audios at the same time, and exposes volume controls. - DESC - s.homepage = 'https://github.com/luanpotter/audioplayer' - s.license = { :file => '../LICENSE' } - s.author = { 'Luan Nico' => 'luannico27@gmail.com' } - s.source = { :path => '.' } - s.source_files = 'Classes/**/*' - s.public_header_files = 'Classes/**/*.h' - s.dependency 'FlutterMacOS' - - s.platform = :osx, '10.11' - s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } -end diff --git a/packages/audioplayers/pubspec.lock b/packages/audioplayers/pubspec.lock deleted file mode 100644 index 054ab2d7c..000000000 --- a/packages/audioplayers/pubspec.lock +++ /dev/null @@ -1,390 +0,0 @@ -# Generated by pub -# See https://dart.dev/tools/pub/glossary#lockfile -packages: - _fe_analyzer_shared: - dependency: transitive - description: - name: _fe_analyzer_shared - url: "https://pub.dartlang.org" - source: hosted - version: "24.0.0" - analyzer: - dependency: transitive - description: - name: analyzer - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - args: - dependency: transitive - description: - name: args - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - async: - dependency: transitive - description: - name: async - url: "https://pub.dartlang.org" - source: hosted - version: "2.8.2" - boolean_selector: - dependency: transitive - description: - name: boolean_selector - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - characters: - dependency: transitive - description: - name: characters - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - charcode: - dependency: transitive - description: - name: charcode - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.1" - cli_util: - dependency: transitive - description: - name: cli_util - url: "https://pub.dartlang.org" - source: hosted - version: "0.3.0" - clock: - dependency: transitive - description: - name: clock - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - collection: - dependency: transitive - description: - name: collection - url: "https://pub.dartlang.org" - source: hosted - version: "1.15.0" - convert: - dependency: transitive - description: - name: convert - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0" - crypto: - dependency: transitive - description: - name: crypto - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0" - csslib: - dependency: transitive - description: - name: csslib - url: "https://pub.dartlang.org" - source: hosted - version: "0.17.0" - dartdoc: - dependency: "direct dev" - description: - name: dartdoc - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - fake_async: - dependency: transitive - description: - name: fake_async - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - ffi: - dependency: transitive - description: - name: ffi - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - file: - dependency: transitive - description: - name: file - url: "https://pub.dartlang.org" - source: hosted - version: "6.1.0" - flame_lint: - dependency: "direct dev" - description: - name: flame_lint - url: "https://pub.dartlang.org" - source: hosted - version: "0.0.1" - flutter: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - flutter_test: - dependency: "direct dev" - description: flutter - source: sdk - version: "0.0.0" - flutter_web_plugins: - dependency: "direct main" - description: flutter - source: sdk - version: "0.0.0" - glob: - dependency: transitive - description: - name: glob - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - html: - dependency: transitive - description: - name: html - url: "https://pub.dartlang.org" - source: hosted - version: "0.15.0" - http: - dependency: "direct main" - description: - name: http - url: "https://pub.dartlang.org" - source: hosted - version: "0.13.1" - http_parser: - dependency: transitive - description: - name: http_parser - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.0" - js: - dependency: transitive - description: - name: js - url: "https://pub.dartlang.org" - source: hosted - version: "0.6.3" - logging: - dependency: transitive - description: - name: logging - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.1" - markdown: - dependency: transitive - description: - name: markdown - url: "https://pub.dartlang.org" - source: hosted - version: "4.0.0" - matcher: - dependency: transitive - description: - name: matcher - url: "https://pub.dartlang.org" - source: hosted - version: "0.12.11" - meta: - dependency: transitive - description: - name: meta - url: "https://pub.dartlang.org" - source: hosted - version: "1.7.0" - package_config: - dependency: transitive - description: - name: package_config - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - path: - dependency: transitive - description: - name: path - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.0" - path_provider: - dependency: "direct main" - description: - name: path_provider - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - path_provider_linux: - dependency: transitive - description: - name: path_provider_linux - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - path_provider_macos: - dependency: transitive - description: - name: path_provider_macos - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - path_provider_platform_interface: - dependency: transitive - description: - name: path_provider_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.1" - path_provider_windows: - dependency: transitive - description: - name: path_provider_windows - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - pedantic: - dependency: transitive - description: - name: pedantic - url: "https://pub.dartlang.org" - source: hosted - version: "1.11.0" - platform: - dependency: transitive - description: - name: platform - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.0" - plugin_platform_interface: - dependency: transitive - description: - name: plugin_platform_interface - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - process: - dependency: transitive - description: - name: process - url: "https://pub.dartlang.org" - source: hosted - version: "4.1.0" - pub_semver: - dependency: transitive - description: - name: pub_semver - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - sky_engine: - dependency: transitive - description: flutter - source: sdk - version: "0.0.99" - source_span: - dependency: transitive - description: - name: source_span - url: "https://pub.dartlang.org" - source: hosted - version: "1.8.1" - stack_trace: - dependency: transitive - description: - name: stack_trace - url: "https://pub.dartlang.org" - source: hosted - version: "1.10.0" - stream_channel: - dependency: transitive - description: - name: stream_channel - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.0" - string_scanner: - dependency: transitive - description: - name: string_scanner - url: "https://pub.dartlang.org" - source: hosted - version: "1.1.0" - term_glyph: - dependency: transitive - description: - name: term_glyph - url: "https://pub.dartlang.org" - source: hosted - version: "1.2.0" - test_api: - dependency: transitive - description: - name: test_api - url: "https://pub.dartlang.org" - source: hosted - version: "0.4.3" - typed_data: - dependency: transitive - description: - name: typed_data - url: "https://pub.dartlang.org" - source: hosted - version: "1.3.0" - uuid: - dependency: "direct main" - description: - name: uuid - url: "https://pub.dartlang.org" - source: hosted - version: "3.0.1" - vector_math: - dependency: transitive - description: - name: vector_math - url: "https://pub.dartlang.org" - source: hosted - version: "2.1.1" - watcher: - dependency: transitive - description: - name: watcher - url: "https://pub.dartlang.org" - source: hosted - version: "1.0.0" - win32: - dependency: transitive - description: - name: win32 - url: "https://pub.dartlang.org" - source: hosted - version: "2.0.0" - xdg_directories: - dependency: transitive - description: - name: xdg_directories - url: "https://pub.dartlang.org" - source: hosted - version: "0.2.0" - yaml: - dependency: transitive - description: - name: yaml - url: "https://pub.dartlang.org" - source: hosted - version: "3.1.0" -sdks: - dart: ">=2.14.0 <3.0.0" - flutter: ">=1.20.0" diff --git a/packages/audioplayers/pubspec.yaml b/packages/audioplayers/pubspec.yaml index e39596274..d98f32808 100644 --- a/packages/audioplayers/pubspec.yaml +++ b/packages/audioplayers/pubspec.yaml @@ -1,38 +1,51 @@ name: audioplayers +resolution: workspace description: A Flutter plugin to play multiple audio files simultaneously -version: 0.20.1 -homepage: https://github.com/luanpotter/audioplayers +version: 6.5.1 +homepage: https://github.com/bluefireteam/audioplayers +repository: https://github.com/bluefireteam/audioplayers/tree/master/packages/audioplayers flutter: plugin: platforms: android: - package: xyz.luan.audioplayers - pluginClass: AudioplayersPlugin + default_package: audioplayers_android ios: - pluginClass: AudioplayersPlugin + default_package: audioplayers_darwin + linux: + default_package: audioplayers_linux macos: - pluginClass: AudioplayersPlugin + default_package: audioplayers_darwin web: - pluginClass: AudioplayersPlugin - fileName: web/audioplayers_web.dart + default_package: audioplayers_web + windows: + default_package: audioplayers_windows dependencies: - uuid: ^3.0.1 - path_provider: ^2.0.1 - http: ^0.13.1 + audioplayers_android: ^5.2.1 + audioplayers_darwin: ^6.3.0 + audioplayers_linux: ^4.2.1 + audioplayers_platform_interface: ^7.1.1 + audioplayers_web: ^5.1.1 + audioplayers_windows: ^4.2.1 + file: '>=6.1.0 <8.0.0' flutter: sdk: flutter - flutter_web_plugins: - sdk: flutter + http: '>=0.13.1 <2.0.0' + meta: ^1.7.0 + path_provider: ^2.0.12 + synchronized: ^3.0.0 + uuid: '>=3.0.7 <5.0.0' dev_dependencies: + flame_lint: ^1.4.1 flutter_test: sdk: flutter - flame_lint: 0.0.1 - dartdoc: ^2.0.0 environment: - sdk: ">=2.14.0 <3.0.0" - flutter: ">=1.10.0" + sdk: ^3.6.0 + flutter: '>=3.27.0' +topics: + - audio + - audio-player diff --git a/packages/audioplayers/test/assets/audio.mp3 b/packages/audioplayers/test/assets/audio.mp3 deleted file mode 100644 index 4fabf9680..000000000 Binary files a/packages/audioplayers/test/assets/audio.mp3 and /dev/null differ diff --git a/packages/audioplayers/test/audio_cache_test.dart b/packages/audioplayers/test/audio_cache_test.dart index 26fc03a2d..13d626e5c 100644 --- a/packages/audioplayers/test/audio_cache_test.dart +++ b/packages/audioplayers/test/audio_cache_test.dart @@ -1,53 +1,73 @@ +import 'dart:convert'; +import 'dart:typed_data'; + import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter/services.dart'; +import 'package:file/memory.dart'; import 'package:flutter_test/flutter_test.dart'; -class MyAudioCache extends AudioCache { +class FakeAudioCache extends AudioCache { List called = []; - MyAudioCache({String prefix = 'assets/', AudioPlayer? fixedPlayer}) - : super(prefix: prefix, fixedPlayer: fixedPlayer); + FakeAudioCache({super.prefix, super.cacheId}); @override Future fetchToMemory(String fileName) async { called.add(fileName); - return Uri.parse('test/assets/$fileName'); + return super.fetchToMemory(fileName); + } + + @override + Future loadAsset(String path) async { + return ByteData.sublistView(utf8.encode(path)); } + + @override + Future getTempDir() async => '/'; } void main() { TestWidgetsFlutterBinding.ensureInitialized(); - const _channel = MethodChannel('plugins.flutter.io/path_provider'); - _channel.setMockMethodCallHandler((c) async => '/tmp'); - - const channel = MethodChannel('xyz.luan/audioplayers'); - channel.setMockMethodCallHandler((MethodCall call) async => 1); + setUp(() { + AudioCache.fileSystem = MemoryFileSystem.test(); + }); group('AudioCache', () { test('sets cache', () async { - final player = MyAudioCache(); - await player.load('audio.mp3'); - expect(player.loadedFiles['audio.mp3'], isNotNull); - expect(player.called, hasLength(1)); - player.called.clear(); - - await player.load('audio.mp3'); - expect(player.called, hasLength(0)); + final cache = FakeAudioCache(); + await cache.load('audio.mp3'); + expect(cache.loadedFiles['audio.mp3'], isNotNull); + expect(cache.called, hasLength(1)); + cache.called.clear(); + + await cache.load('audio.mp3'); + expect(cache.called, hasLength(0)); + }); + + test('clear cache', () async { + final cache = FakeAudioCache(); + await cache.load('audio.mp3'); + expect(cache.loadedFiles['audio.mp3'], isNotNull); + await cache.clearAll(); + expect(cache.loadedFiles, {}); + await cache.load('audio.mp3'); + expect(cache.loadedFiles.isNotEmpty, isTrue); + await cache.clear('audio.mp3'); + expect(cache.loadedFiles, {}); }); - test('fixedPlayer vs non fixedPlayer', () async { - final fixed = MyAudioCache(fixedPlayer: AudioPlayer()); - final fixedId = fixed.fixedPlayer!.playerId; - final regular = MyAudioCache(); + test('Use different location for two audio caches', () async { + const fileName = 'audio.mp3'; + final cacheA = FakeAudioCache(cacheId: 'cache-path-A'); + await cacheA.load(fileName); + expect(cacheA.loadedFiles[fileName]?.path, '//cache-path-A/audio.mp3'); - final a1 = await fixed.play('audio.mp3'); - expect(a1.playerId, fixedId); - final a2 = await fixed.play('audio.mp3'); - expect(a2.playerId, fixedId); + final cacheB = FakeAudioCache(cacheId: 'cache-path-B'); + await cacheB.load(fileName); + expect(cacheB.loadedFiles[fileName]?.path, '//cache-path-B/audio.mp3'); - final a3 = await regular.play('audio.mp3'); - expect(a3.playerId, isNot(fixedId)); + await cacheA.clearAll(); + await cacheB.clearAll(); }); }); } diff --git a/packages/audioplayers/test/audio_pool_test.dart b/packages/audioplayers/test/audio_pool_test.dart new file mode 100644 index 000000000..f8ae4b29a --- /dev/null +++ b/packages/audioplayers/test/audio_pool_test.dart @@ -0,0 +1,83 @@ +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:file/memory.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'audio_cache_test.dart'; +import 'fake_audioplayers_platform.dart'; +import 'fake_global_audioplayers_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('AudioPool', () { + setUp(() { + AudioplayersPlatformInterface.instance = FakeAudioplayersPlatform(); + GlobalAudioplayersPlatformInterface.instance = + FakeGlobalAudioplayersPlatform(); + AudioCache.fileSystem = MemoryFileSystem.test(); + }); + + test('creates instance', () async { + final pool = await AudioPool.createFromAsset( + path: 'audio.mp3', + maxPlayers: 3, + audioCache: FakeAudioCache(), + ); + final stop = await pool.start(); + + expect((pool.source as AssetSource).path, 'audio.mp3'); + expect(pool.audioCache.loadedFiles.keys.first, 'audio.mp3'); + stop(); + expect((pool.source as AssetSource).path, 'audio.mp3'); + }); + + test('multiple players running', () async { + final pool = await AudioPool.createFromAsset( + path: 'audio.mp3', + maxPlayers: 3, + audioCache: FakeAudioCache(), + ); + final stop1 = await pool.start(); + final stop2 = await pool.start(); + final stop3 = await pool.start(); + + expect((pool.source as AssetSource).path, 'audio.mp3'); + expect(pool.audioCache.loadedFiles.keys.first, 'audio.mp3'); + expect(pool.availablePlayers.isEmpty, isTrue); + expect(pool.currentPlayers.length, 3); + + await stop1(); + await stop2(); + await stop3(); + expect(pool.availablePlayers.length, 3); + expect(pool.currentPlayers.isEmpty, isTrue); + }); + + test('keeps the minPlayers/maxPlayers contract', () async { + final pool = await AudioPool.createFromAsset( + path: 'audio.mp3', + maxPlayers: 3, + audioCache: FakeAudioCache(), + ); + final stopFunctions = + await Future.wait(List.generate(5, (_) => pool.start())); + + expect(pool.availablePlayers.isEmpty, isTrue); + expect(pool.currentPlayers.length, 5); + + await stopFunctions[0](); + await stopFunctions[1](); + + expect(pool.availablePlayers.length, 2); + expect(pool.currentPlayers.length, 3); + + await stopFunctions[2](); + await stopFunctions[3](); + await stopFunctions[4](); + + expect(pool.availablePlayers.length, 3); + expect(pool.currentPlayers.isEmpty, isTrue); + }); + }); +} diff --git a/packages/audioplayers/test/audioplayers_test.dart b/packages/audioplayers/test/audioplayers_test.dart index a26d3457a..35c084beb 100644 --- a/packages/audioplayers/test/audioplayers_test.dart +++ b/packages/audioplayers/test/audioplayers_test.dart @@ -1,75 +1,159 @@ import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter/services.dart'; +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; -extension _Args on MethodCall { - Map get args => arguments as Map; - - String getString(String key) { - return args[key] as String; - } -} +import 'fake_audioplayers_platform.dart'; +import 'fake_global_audioplayers_platform.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - final calls = []; - const channel = MethodChannel('xyz.luan/audioplayers'); - channel.setMockMethodCallHandler((MethodCall call) async { - calls.add(call); - return 0; + final globalPlatform = FakeGlobalAudioplayersPlatform(); + GlobalAudioplayersPlatformInterface.instance = globalPlatform; + + late FakeAudioplayersPlatform platform; + setUp(() { + platform = FakeAudioplayersPlatform(); + AudioplayersPlatformInterface.instance = platform; }); - MethodCall popCall() { - expect(calls, hasLength(1)); - return calls.removeAt(0); + Future createPlayer({ + required String playerId, + }) async { + final player = AudioPlayer(playerId: playerId); + // Avoid unpredictable position updates + player.positionUpdater = null; + expect(player.source, null); + await player.creatingCompleter.future; + expect(platform.popCall().method, 'create'); + expect(platform.popLastCall().method, 'getEventStream'); + return player; } - group('AudioPlayers', () { + group('AudioPlayer Methods', () { + late AudioPlayer player; + + setUp(() async { + player = await createPlayer(playerId: 'p1'); + expect(player.source, null); + }); + + test('#setSource and #dispose', () async { + await player.setSource(UrlSource('internet.com/file.mp3')); + expect(platform.popLastCall().method, 'setSourceUrl'); + expect(player.source, isInstanceOf()); + final urlSource = player.source as UrlSource?; + expect(urlSource?.url, 'internet.com/file.mp3'); + + await player.dispose(); + expect(platform.popCall().method, 'stop'); + expect(platform.popCall().method, 'release'); + expect(platform.popLastCall().method, 'dispose'); + expect(player.source, null); + }); + test('#play', () async { - calls.clear(); - final player = AudioPlayer(); - await player.play('internet.com/file.mp3'); - final call = popCall(); - expect(call.method, 'play'); - expect(call.getString('url'), 'internet.com/file.mp3'); + await player.play(UrlSource('internet.com/file.mp3')); + final call1 = platform.popCall(); + expect(call1.method, 'setSourceUrl'); + expect(call1.value, 'internet.com/file.mp3'); + expect(platform.popLastCall().method, 'resume'); }); test('multiple players', () async { - calls.clear(); - final player1 = AudioPlayer(); - final player2 = AudioPlayer(); + final player2 = await createPlayer(playerId: 'p2'); - await player1.play('internet.com/file.mp3'); - final call = popCall(); - final player1Id = call.getString('playerId'); - expect(call.method, 'play'); - expect(call.getString('url'), 'internet.com/file.mp3'); + await player.play(UrlSource('internet.com/file.mp3')); + final call1 = platform.popCall(); + expect(call1.id, 'p1'); + expect(call1.method, 'setSourceUrl'); + expect(call1.value, 'internet.com/file.mp3'); + expect(platform.popLastCall().method, 'resume'); - await player1.play('internet.com/file.mp3'); - expect(popCall().getString('playerId'), player1Id); + platform.clear(); + await player.play(UrlSource('internet.com/file.mp3')); + expect(platform.popCall().id, 'p1'); - await player2.play('internet.com/file.mp3'); - expect(popCall().getString('playerId'), isNot(player1Id)); + platform.clear(); + await player2.play(UrlSource('internet.com/file.mp3')); + expect(platform.popCall().id, 'p2'); - await player1.play('internet.com/file.mp3'); - expect(popCall().getString('playerId'), player1Id); + platform.clear(); + await player.play(UrlSource('internet.com/file.mp3')); + expect(platform.popCall().id, 'p1'); }); test('#resume, #pause and #duration', () async { - calls.clear(); - final player = AudioPlayer(); - await player.setUrl('assets/audio.mp3'); - expect(popCall().method, 'setUrl'); + await player.setSourceUrl('assets/audio.mp3'); + expect(platform.popLastCall().method, 'setSourceUrl'); await player.resume(); - expect(popCall().method, 'resume'); + expect(platform.popLastCall().method, 'resume'); await player.getDuration(); - expect(popCall().method, 'getDuration'); + expect(platform.popLastCall().method, 'getDuration'); await player.pause(); - expect(popCall().method, 'pause'); + expect(platform.popLastCall().method, 'pause'); + }); + + test('set #volume, #balance, #playbackRate, #playerMode, #releaseMode', + () async { + await player.setVolume(0.1); + expect(player.volume, 0.1); + expect(platform.popLastCall().method, 'setVolume'); + + await player.setBalance(0.2); + expect(player.balance, 0.2); + expect(platform.popLastCall().method, 'setBalance'); + + await player.setPlaybackRate(0.3); + expect(player.playbackRate, 0.3); + expect(platform.popLastCall().method, 'setPlaybackRate'); + + await player.setPlayerMode(PlayerMode.lowLatency); + expect(player.mode, PlayerMode.lowLatency); + expect(platform.popLastCall().method, 'setPlayerMode'); + + await player.setReleaseMode(ReleaseMode.loop); + expect(player.releaseMode, ReleaseMode.loop); + expect(platform.popLastCall().method, 'setReleaseMode'); + }); + }); + + group('AudioPlayers Events', () { + late AudioPlayer player; + + setUp(() async { + player = await createPlayer(playerId: 'p1'); + expect(player.source, null); + }); + + test('event stream', () async { + final audioEvents = [ + const AudioEvent( + eventType: AudioEventType.duration, + duration: Duration(milliseconds: 98765), + ), + const AudioEvent( + eventType: AudioEventType.log, + logMessage: 'someLogMessage', + ), + const AudioEvent( + eventType: AudioEventType.complete, + ), + const AudioEvent( + eventType: AudioEventType.seekComplete, + ), + ]; + + expect( + player.eventStream, + emitsInOrder(audioEvents), + ); + + audioEvents.forEach(platform.eventStreamControllers['p1']!.add); + await platform.eventStreamControllers['p1']!.close(); }); }); } diff --git a/packages/audioplayers/test/fake_audioplayers_platform.dart b/packages/audioplayers/test/fake_audioplayers_platform.dart new file mode 100644 index 000000000..40c49a44f --- /dev/null +++ b/packages/audioplayers/test/fake_audioplayers_platform.dart @@ -0,0 +1,166 @@ +import 'dart:async'; + +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeCall { + final String id; + final String method; + final Object? value; + + FakeCall({required this.id, required this.method, this.value}); + + @override + String toString() => 'FakeCall(id: $id, method: $method, value: $value)'; +} + +class FakeAudioplayersPlatform extends AudioplayersPlatformInterface { + List calls = []; + + Map> eventStreamControllers = {}; + + void clear() { + calls.clear(); + } + + FakeCall popCall() { + return calls.removeAt(0); + } + + FakeCall popLastCall() { + expect(calls, hasLength(1)); + return popCall(); + } + + @override + Future create(String playerId) async { + calls.add(FakeCall(id: playerId, method: 'create')); + eventStreamControllers[playerId] = StreamController.broadcast(); + } + + @override + Future dispose(String playerId) async { + calls.add(FakeCall(id: playerId, method: 'dispose')); + eventStreamControllers[playerId]?.close(); + } + + @override + Future emitError(String playerId, String code, String message) async { + calls.add(FakeCall(id: playerId, method: 'emitError')); + } + + @override + Future emitLog(String playerId, String message) async { + calls.add(FakeCall(id: playerId, method: 'emitLog')); + } + + @override + Future getCurrentPosition(String playerId) async { + calls.add(FakeCall(id: playerId, method: 'getCurrentPosition')); + return 0; + } + + @override + Future getDuration(String playerId) async { + calls.add(FakeCall(id: playerId, method: 'getDuration')); + return 0; + } + + @override + Future pause(String playerId) async { + calls.add(FakeCall(id: playerId, method: 'pause')); + } + + @override + Future release(String playerId) async { + calls.add(FakeCall(id: playerId, method: 'release')); + } + + @override + Future resume(String playerId) async { + calls.add(FakeCall(id: playerId, method: 'resume')); + } + + @override + Future seek(String playerId, Duration position) async { + calls.add(FakeCall(id: playerId, method: 'seek', value: position)); + } + + @override + Future setAudioContext( + String playerId, + AudioContext audioContext, + ) async { + calls.add( + FakeCall(id: playerId, method: 'setAudioContext', value: audioContext), + ); + } + + @override + Future setBalance(String playerId, double balance) async { + calls.add(FakeCall(id: playerId, method: 'setBalance', value: balance)); + } + + @override + Future setPlaybackRate(String playerId, double playbackRate) async { + calls.add( + FakeCall(id: playerId, method: 'setPlaybackRate', value: playbackRate), + ); + } + + @override + Future setPlayerMode(String playerId, PlayerMode playerMode) async { + calls.add( + FakeCall(id: playerId, method: 'setPlayerMode', value: playerMode), + ); + } + + @override + Future setReleaseMode(String playerId, ReleaseMode releaseMode) async { + calls.add( + FakeCall(id: playerId, method: 'setReleaseMode', value: releaseMode), + ); + } + + @override + Future setSourceBytes( + String playerId, + Uint8List bytes, { + String? mimeType, + }) async { + calls.add(FakeCall(id: playerId, method: 'setSourceBytes', value: bytes)); + eventStreamControllers[playerId]?.add( + const AudioEvent(eventType: AudioEventType.prepared, isPrepared: true), + ); + } + + @override + Future setSourceUrl( + String playerId, + String url, { + bool? isLocal, + String? mimeType, + }) async { + calls.add(FakeCall(id: playerId, method: 'setSourceUrl', value: url)); + eventStreamControllers[playerId]?.add( + const AudioEvent(eventType: AudioEventType.prepared, isPrepared: true), + ); + } + + @override + Future setVolume(String playerId, double volume) async { + calls.add(FakeCall(id: playerId, method: 'setVolume', value: volume)); + } + + @override + Future stop(String playerId) async { + calls.add(FakeCall(id: playerId, method: 'stop')); + } + + @override + Stream getEventStream(String playerId) { + calls.add(FakeCall(id: playerId, method: 'getEventStream')); + return eventStreamControllers[playerId]!.stream; + } +} diff --git a/packages/audioplayers/test/fake_global_audioplayers_platform.dart b/packages/audioplayers/test/fake_global_audioplayers_platform.dart new file mode 100644 index 000000000..af3aa1104 --- /dev/null +++ b/packages/audioplayers/test/fake_global_audioplayers_platform.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class FakeGlobalCall { + final String method; + final Object? value; + + FakeGlobalCall({required this.method, this.value}); +} + +class FakeGlobalAudioplayersPlatform + extends GlobalAudioplayersPlatformInterface { + List calls = []; + StreamController eventStreamController = + StreamController.broadcast(); + + void clear() { + calls.clear(); + } + + FakeGlobalCall popCall() { + return calls.removeAt(0); + } + + FakeGlobalCall popLastCall() { + expect(calls, hasLength(1)); + return popCall(); + } + + @override + Future init() async { + calls.add(FakeGlobalCall(method: 'init')); + } + + @override + Future setGlobalAudioContext(AudioContext ctx) async { + calls.add(FakeGlobalCall(method: 'setGlobalAudioContext', value: ctx)); + } + + @override + Future emitGlobalLog(String message) async { + calls.add(FakeGlobalCall(method: 'emitGlobalLog')); + eventStreamController.add( + GlobalAudioEvent( + eventType: GlobalAudioEventType.log, + logMessage: message, + ), + ); + } + + @override + Future emitGlobalError(String code, String message) async { + calls.add(FakeGlobalCall(method: 'emitGlobalError')); + eventStreamController + .addError(PlatformException(code: code, message: message)); + } + + @override + Stream getGlobalEventStream() { + calls.add(FakeGlobalCall(method: 'getGlobalEventStream')); + return eventStreamController.stream; + } + + Future dispose() async { + calls.add(FakeGlobalCall(method: 'globalDispose')); + eventStreamController.close(); + } +} diff --git a/packages/audioplayers/test/global_audioplayers_test.dart b/packages/audioplayers/test/global_audioplayers_test.dart new file mode 100644 index 000000000..5e930cb52 --- /dev/null +++ b/packages/audioplayers/test/global_audioplayers_test.dart @@ -0,0 +1,79 @@ +//ignore_for_file: avoid_redundant_argument_values +import 'package:audioplayers/audioplayers.dart'; +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'fake_global_audioplayers_platform.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final globalPlatform = FakeGlobalAudioplayersPlatform(); + GlobalAudioplayersPlatformInterface.instance = globalPlatform; + + late GlobalAudioScope globalScope; + + test('test getGlobalEventStream', () async { + // Global scope can only be initialized once statically, as changing it + // while connected to native platform can lead to inconsistencies. + globalScope = AudioPlayer.global; + expect(globalPlatform.popLastCall().method, 'getGlobalEventStream'); + }); + + group('Global Methods', () { + setUp(() { + // Ensure that globalScope was initialized and calls are reset. + globalScope = AudioPlayer.global; + globalPlatform.clear(); + }); + + /// Note that the [AudioContextIOS.category] has to be + /// [AVAudioSessionCategory.playback] to default the audio to the receiver + /// (e.g. built-in speakers or BT-device, if connected). + /// If using [AVAudioSessionCategory.playAndRecord] the audio will come from + /// the earpiece unless [AVAudioSessionOptions.defaultToSpeaker] is used. + test('set AudioContext', () async { + await globalScope.setAudioContext(AudioContext()); + var call = globalPlatform.popCall(); + expect(call.method, 'init'); + call = globalPlatform.popLastCall(); + expect(call.method, 'setGlobalAudioContext'); + expect( + call.value, + AudioContext( + android: const AudioContextAndroid( + isSpeakerphoneOn: false, + audioMode: AndroidAudioMode.normal, + stayAwake: false, + contentType: AndroidContentType.music, + usageType: AndroidUsageType.media, + audioFocus: AndroidAudioFocus.gain, + ), + iOS: AudioContextIOS( + category: AVAudioSessionCategory.playback, + options: const {}, + ), + ), + ); + }); + }); + + group('Global Events', () { + test('global event stream', () async { + final globalEvents = [ + const GlobalAudioEvent( + eventType: GlobalAudioEventType.log, + logMessage: 'someLogMessage', + ), + ]; + + expect( + globalScope.eventStream, + emitsInOrder(globalEvents), + ); + + globalEvents.forEach(globalPlatform.eventStreamController.add); + await globalPlatform.eventStreamController.close(); + }); + }); +} diff --git a/packages/audioplayers/test/logger_test.dart b/packages/audioplayers/test/logger_test.dart index 1500844a7..c9c7f5f86 100644 --- a/packages/audioplayers/test/logger_test.dart +++ b/packages/audioplayers/test/logger_test.dart @@ -1,66 +1,70 @@ import 'dart:async'; import 'package:audioplayers/audioplayers.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); - const _channel = MethodChannel('plugins.flutter.io/path_provider'); - _channel.setMockMethodCallHandler((c) async => '/tmp'); - - const channel = MethodChannel('xyz.luan/audioplayers'); - channel.setMockMethodCallHandler((MethodCall call) async => 1); - - final _print = OverridePrint(); + final printZone = OverridePrint(); group('Logger', () { - setUp(_print.clear); + setUp(printZone.clear); + test( 'when set to INFO everything is logged', - _print.overridePrint(() { - Logger.changeLogLevel(LogLevel.info); - Logger.log(LogLevel.info, 'info'); - Logger.log(LogLevel.error, 'error'); + printZone.overridePrint(() { + AudioLogger.logLevel = AudioLogLevel.info; + + AudioLogger.log('info'); + AudioLogger.error('error'); - expect(_print.log, ['info', 'error']); + expect(printZone.logs, [ + 'AudioPlayers Log: info', + '\x1B[31mAudioPlayers throw: error\x1B[0m', + ]); }), ); + test( 'when set to ERROR only errors are logged', - _print.overridePrint(() { - Logger.changeLogLevel(LogLevel.error); - Logger.log(LogLevel.info, 'info'); - Logger.log(LogLevel.error, 'error'); + printZone.overridePrint(() { + AudioLogger.logLevel = AudioLogLevel.error; + + AudioLogger.log('info'); + AudioLogger.error('error'); - expect(_print.log, ['error']); + expect(printZone.logs, [ + '\x1B[31mAudioPlayers throw: error\x1B[0m', + ]); }), ); + test( 'when set to NONE nothing is logged', - _print.overridePrint(() { - Logger.changeLogLevel(LogLevel.none); - Logger.log(LogLevel.info, 'info'); - Logger.log(LogLevel.error, 'error'); + printZone.overridePrint(() { + AudioLogger.logLevel = AudioLogLevel.none; + + AudioLogger.log('info'); + AudioLogger.error('error'); - expect(_print.log, []); + expect(printZone.logs, []); }), ); }); } class OverridePrint { - final log = []; + final logs = []; - void clear() => log.clear(); + void clear() => logs.clear(); void Function() overridePrint(void Function() testFn) { return () { final spec = ZoneSpecification( print: (_, __, ___, String msg) { // Add to log instead of printing to stdout - log.add(msg); + logs.add(msg); }, ); return Zone.current.fork(specification: spec).run(testFn); diff --git a/packages/audioplayers/test/uri_coder_test.dart b/packages/audioplayers/test/uri_coder_test.dart new file mode 100644 index 000000000..8170c4d26 --- /dev/null +++ b/packages/audioplayers/test/uri_coder_test.dart @@ -0,0 +1,41 @@ +import 'package:audioplayers/src/uri_ext.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + group('UriCoder', () { + test( + 'Encode Special Character', + () { + const uri = '/coins_non_ascii_и.wav'; + final encoded = UriCoder.encodeOnce(uri); + expect(encoded, '/coins_non_ascii_%D0%B8.wav'); + }, + ); + test( + 'Encode Space', + () { + const uri = '/coins .wav'; + final encoded = UriCoder.encodeOnce(uri); + expect(encoded, '/coins%20.wav'); + }, + ); + test( + 'Already encoded Character', + () { + const uri = 'https://myurl/audio%2F_music.mp4?alt=media&token=abc'; + final encoded = UriCoder.encodeOnce(uri); + expect(encoded, uri); + }, + ); + test( + 'Encoded and decoded are the same', + () { + const uri = 'https://myurl/audio'; + final encoded = UriCoder.encodeOnce(uri); + expect(encoded, uri); + }, + ); + }); +} diff --git a/packages/audioplayers_android/.gitignore b/packages/audioplayers_android/.gitignore new file mode 100644 index 000000000..04c2400af --- /dev/null +++ b/packages/audioplayers_android/.gitignore @@ -0,0 +1,17 @@ +*.iml +.DS_Store +.atom/ +.idea +.packages +.dart_tool/ +.pub/ +build/ +ios/.generated/ +packages +.classpath +.project +.settings +.vscode +testing +.flutter-plugins-dependencies +flutter_export_environment.sh diff --git a/packages/audioplayers_android/.metadata b/packages/audioplayers_android/.metadata new file mode 100644 index 000000000..434f1e5eb --- /dev/null +++ b/packages/audioplayers_android/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: a0860f6e87ba4f9031bee4d6f56c08b970606bee + channel: dev + +project_type: plugin diff --git a/packages/audioplayers_android/CHANGELOG.md b/packages/audioplayers_android/CHANGELOG.md new file mode 100644 index 000000000..3c9b5ffda --- /dev/null +++ b/packages/audioplayers_android/CHANGELOG.md @@ -0,0 +1,422 @@ +## 5.2.1 + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +## 5.2.0 + + - **FIX**: Make FocusManager compatible with Android API <= 25 (closes [#1895](https://github.com/bluefireteam/audioplayers/issues/1895)) ([#1904](https://github.com/bluefireteam/audioplayers/issues/1904)). ([41238d48](https://github.com/bluefireteam/audioplayers/commit/41238d4837fb5c59b8aaf2e7e8087268a160ebe7)) + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +## 5.1.0 + + - **FEAT**(android): ExoPlayer for Android ([#1691](https://github.com/bluefireteam/audioplayers/issues/1691)). ([a91c5b18](https://github.com/bluefireteam/audioplayers/commit/a91c5b185054986a2390d41593b5ee502ef96bdd)) + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +## 5.0.3 + + - **FIX**: Raise Android SDK versions ([#1885](https://github.com/bluefireteam/audioplayers/issues/1885)). ([7230bc84](https://github.com/bluefireteam/audioplayers/commit/7230bc84d9dfb0cccfbe5bacb971ef3698495176)) + +## 5.0.2 + + - **FIX**: Change audioFocus dynamically ([#1877](https://github.com/bluefireteam/audioplayers/issues/1877)). ([14f16d9d](https://github.com/bluefireteam/audioplayers/commit/14f16d9dfc52e2eca989e0cc6a27decb8e96af83)) + +## 5.0.1 + + - **FIX**: Avoid multiple audioFocusRequest instances for focus changes ([#1869](https://github.com/bluefireteam/audioplayers/issues/1869)). ([040dde9c](https://github.com/bluefireteam/audioplayers/commit/040dde9c2b1d4601a4c4790fa4a43a4cdd4e9a27)) + - **FIX**(android): Avoid playing after gaining focus in paused state ([#1857](https://github.com/bluefireteam/audioplayers/issues/1857)). ([01726c13](https://github.com/bluefireteam/audioplayers/commit/01726c1362135a4c3595169dcb1adb311f25f683)) + +## 5.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + +## 4.0.3 + + - **FIX**(android): Released wrong source in LOW_LATENCY mode ([#1672](https://github.com/bluefireteam/audioplayers/issues/1672)). ([d9c5f693](https://github.com/bluefireteam/audioplayers/commit/d9c5f693cafab21b67b785de6244c3c371344a53)) + +## 4.0.2 + + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Set playback rate only when playing ([#1658](https://github.com/bluefireteam/audioplayers/issues/1658)). ([d73c7d5c](https://github.com/bluefireteam/audioplayers/commit/d73c7d5c2ef13e8eff2c438b96ade6e2483a2014)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + +## 4.0.1 + + - **REVERT**(android): Upgrade androidx.core:core-ktx, restore support for AGP7 ([#1590](https://github.com/bluefireteam/audioplayers/issues/1590)). ([f6bf1260](https://github.com/bluefireteam/audioplayers/commit/f6bf12609ec9e457451f1c786522bff28a1555f4)) + +## 4.0.0 + +> Note: This release has breaking changes. + + - **FIX**(android): Allow AudioFocus.none ([#1534](https://github.com/bluefireteam/audioplayers/issues/1534)). ([858d3f44](https://github.com/bluefireteam/audioplayers/commit/858d3f4410b1bc7b203090c20cf60b5136dad4fe)) + - **FEAT**(android): Add support for AGP 8 in example, add compileOptions to build.gradle ([#1503](https://github.com/bluefireteam/audioplayers/issues/1503)). ([7c08e4e1](https://github.com/bluefireteam/audioplayers/commit/7c08e4e1a524f53294f6967996fd31837e62cb81)) + - **BREAKING** **FIX**: Default audio output to system preferences ([#1563](https://github.com/bluefireteam/audioplayers/issues/1563)). ([381c43e3](https://github.com/bluefireteam/audioplayers/commit/381c43e3725fbb0cb4fd35982893a3c92b188886)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +## 3.0.2 + + - **FIX**(android): `onComplete` is not called when audio has completed playing ([#1523](https://github.com/bluefireteam/audioplayers/issues/1523)). ([293d6c0e](https://github.com/bluefireteam/audioplayers/commit/293d6c0eec1d89ad200b2914cae0adf644b25013)) + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**(android): Add AGP 8 support with namespace property ([#1514](https://github.com/bluefireteam/audioplayers/issues/1514)). ([8d7b322e](https://github.com/bluefireteam/audioplayers/commit/8d7b322e79fd802fb75ca72f5c8ac388754cd406)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + +## 3.0.1 + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +## 3.0.0 + +> Note: This release has breaking changes. + + - **FIX**(android): Avoid calling onDuration on position event (closes [#136](https://github.com/bluefireteam/audioplayers/issues/136)) ([#1460](https://github.com/bluefireteam/audioplayers/issues/1460)). ([6cfb3753](https://github.com/bluefireteam/audioplayers/commit/6cfb3753cd8003f341d97e0b2417d4512f452267)) + - **FIX**(android): reset prepared state on player error ([#1425](https://github.com/bluefireteam/audioplayers/issues/1425)). ([6f24c8f5](https://github.com/bluefireteam/audioplayers/commit/6f24c8f57e4549edbf7d68a021d1d94371c23f3f)) + - **FEAT**(android): add `setBalance` ([#58](https://github.com/bluefireteam/audioplayers/issues/58)) ([#1444](https://github.com/bluefireteam/audioplayers/issues/1444)). ([3b5de50e](https://github.com/bluefireteam/audioplayers/commit/3b5de50ea7fa5248165616fc1ffd80da6c66583a)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + +## 2.0.0 + +> Note: This release has breaking changes. + + - **FIX**: playing at playback rate `1.0` in android API level < 23 (fixes [#1344](https://github.com/bluefireteam/audioplayers/issues/1344)) ([#1390](https://github.com/bluefireteam/audioplayers/issues/1390)). ([b248e71d](https://github.com/bluefireteam/audioplayers/commit/b248e71dabf923072f1fd14355b4e0230c9a6593)) + - **BREAKING** **FEAT**: configurable SoundPool and `AudioManager.mode` ([#1388](https://github.com/bluefireteam/audioplayers/issues/1388)). ([5697f187](https://github.com/bluefireteam/audioplayers/commit/5697f187bcca64de2e519f8f49aaf4817fcf6398)) + +## 1.1.4 + + - Update a dependency to the latest release. + +## 1.1.3 + + - **FIX**: Avoid ConcurrentModificationException ([#1297](https://github.com/bluefireteam/audioplayers/issues/1297)). ([d15ef5ab](https://github.com/bluefireteam/audioplayers/commit/d15ef5ab93f11e2f19089af08f1533fcdc1397e6)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + +## 1.1.1 + + - **FIX**: Avoid ConcurrentModificationException ([#1297](https://github.com/bluefireteam/audioplayers/issues/1297)). ([d15ef5ab](https://github.com/bluefireteam/audioplayers/commit/d15ef5ab93f11e2f19089af08f1533fcdc1397e6)) + +## 1.1.0 + + - **FIX**: lowLatency bugs (closes #1176, closes #1193, closes #1165) (#1272). ([541578cc](https://github.com/bluefireteam/audioplayers/commit/541578cc50f3856c23c393faa1a71380b3b49222)) + - **FIX**: revert compileSdkVersion to be compatible with flutter.compileSdkVersion (#1273). ([0b9fed43](https://github.com/bluefireteam/audioplayers/commit/0b9fed43d9dfa90870826dc9a34d1a0d730bd78d)) + - **FIX**: emit onPositionChanged when seek is completed (closes #1259) (#1265). ([be7ac6a9](https://github.com/bluefireteam/audioplayers/commit/be7ac6a957fccadf5bcecf0f1fbea197d32bda21)) + - **FIX**: bugs from integration tests (#1247). ([6fad1cc4](https://github.com/bluefireteam/audioplayers/commit/6fad1cc4443e623e5c94519f130b4004b2dc3857)) + - **FIX**: Fix lowLatency mode for Android (#1193) (#1224). ([a25ca284](https://github.com/bluefireteam/audioplayers/commit/a25ca284835252147c85944575c7e71a3ef6abc4)) + - **FEAT**: wait for source to be prepared (#1191). ([5eeca894](https://github.com/bluefireteam/audioplayers/commit/5eeca8940e764546023567fa2f6b1bc3802f97d3)) + +## 1.0.1 + + - **FIX**: getDuration, getPosition causes MEDIA_ERROR_UNKNOWN (#1172). ([51b4c73e](https://github.com/bluefireteam/audioplayers/commit/51b4c73eaff5c60d1c3c3e42ae783df07d34be09)) + +## 1.0.0 + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +## 1.0.0-rc.2 + +## 1.0.0-rc.1 + + - First release after federation + +# Changelog + +## 0.20.2 +- Fix bug with inversed log levels + +## 0.20.1 +- Fix enum parsing on release mode on android + +## 0.20.0 +- Fix android/kotlin build for old projects +- Add method to clearNotification +- Add currentPosition stream on web +- Add seek on web +- Add a proper Logger +- Make setPlaybackRate signature consistent +- Fix fatal exception on Android API < 21 in WrappedMediaPlayer.kt setAttributes +- Add clearNotification method + +## 0.19.1 +- Add missing awaits for AudioCache +- Fix Kotlin Core version to v1.6.0 +- Fix iOS warning +- Fix README link to audio_cache.md to work on pub +- Fix documentation referencing old class +- Add web support for audioPlayer.getCurrentPosition +- Add web support for audioPlayer.getDuration +- Add web support for audioPlayer.setPlaybackRate +- Fix local file playback in LOW_LATENCY mode on Android + +## 0.19.0 +- Refactor Notifications code (small breaking changes) +- AudioCache for web +- Fixing basic features for Android lower than API 23 +- Fixing error after playing music several times with AudioCache +- Re-organize folder and file structure on the Dart side (project layout) +- Re-organize folders into a mono-repo +- Fix several bugs + +## 0.18.3 +- Fix Float vs Double mixup on Swift that prevent non-integer values for volume/playback +- Fix open sink issue / resource leak + +## 0.18.2 +- Changing Android minSdk verison to 16 +- Improve build processes and other small bug fixes + +## 0.18.1 +- Fix kotlin config issue for some apps +- Fix warning from pub +- Fix iOS lock screen +- Fix setUrl method + +## 0.18.0 +- Stable null-safety release +- Removed all the `@deprecated` code blocks + +## 0.17.4 +- Fix java.lang.UnsupportedOperationException on read-only kotlin map + +## 0.17.3 +- Backport some code to old kt (for now) + +## 0.17.2 +- Fix macos compilation issue +- Fix android for non-kotlin projects + +## 0.17.1 +- Use better algorithm for speed modulation on iOS +- Extracted and refactored all the notifications code onto the new file +- Add more checks and make sure notifcations code is not ran when it shouldn't +- Add more useful info to the troubleshoot guide + +## 0.17.0 +- Swift conversion of the darwin code + +## 0.16.2 +- Overhauled our contributing guidelines +- Improve docs around player state +- Update dependencies versions + +## 0.16.1 +- Fix Exception thrown when calling audioPlayer.dispose +- Fix bug with AudioCache crash on iOS + +## 0.16.0 +- Implemented stream routing for iOS +- Call release on dispose +- Fix iOS build +- Breaking change audio cache prefix in order to allow override 'assets' + +## audioplayers 0.15.1 +- Fix web for release mode + +## audioplayers 0.15.0 +- Improve loop/readme for web support +- Audio cache support for web +- Re-adding partial web support + +## audioplayers 0.14.3 +- Add next and previous command for ios + +## audioplayers 0.14.2 +- Fix pubspec problem because of web file + +## audioplayers 0.14.1 +- Adding linter, tests and flutter_driver integration tests to a CI (github actions) +- Minor fixes to the APIs and documentation +- Fix restarting the playback of a failed AVPlayerItem +- Prevent exceptions when null values are passed to notifications center +- Prevent crash by checking if headlessServiceInitialized before invoking onNotificationBackgroundPlayerStateChanged + +## audioplayers 0.14.0 +- Adding macOs support +- ios:fix lack of seek completion handle +- ios Delay start fixed + +## audioplayers 0.13.7 +- Bump dependencies, improve gitignore +- Upgrade pubspec pattern + +## audioplayers 0.13.6 +- added `setPlaybackRate` feature for Android +- Automatic detect address is local or remote (thanks, @saeed-golshan) + +## audioplayers 0.13.5 +- fixed crash on iOS when `startHeadlessService()` wasn't called on `AudioPlayer` (by @JesseScott) + +## audioplayers 0.13.4 +- fixing missing cleanup on hot restart on Android +- Background notification updates on iOS + +## audioplayers 0.13.3 +- audio notification area fixes +- fix when other apps are playing sounds +- fix android race condition +- Support for registering plugin in background enviroment +- fix typos and docs + +## audioplayers 0.13.2 +- Handling plugin dealloc and onTimeInterval crashs (thanks @chedechao111) +- Audio position update when the audio is paused (thanks @bjornjacobs) + +## audioplayers 0.13.1 +- Added stayAwake feature (thanks, @danielR2001) +- Improved dispose method (thanks, @hugocbpassos) +- Added getCurrentPosition (thanks, @hariom08) +- Some bug fixes and small changes + +## audioplayers 0.13.0 +- Call onDurationChanged after setUrl() to be consistent with ios version (thanks @subhash279) +- Adding getDuration feature iOS/Android (thanks @alecorsino) + +## audioplayers 0.12.1 +- Fixes bug where the stream handlers were not called due to exception on the handler +- Proper error message when errors in the dart handler occurs + +## audioplayers 0.12.0 +- Update to path_provider 1.1.0 +- Upgrade to Swift 5 in example project setting (thanks @jerryzhoujw) + +## audioplayers 0.11.0 +- **Breaking change**. Migrate from the deprecated original Android Support + Library to AndroidX. This shouldn't result in any functional changes, but it + requires any Android apps using this plugin to [also + migrate](https://developer.android.com/jetpack/androidx/migrate) if they're + using the original support library. + +## audioplayers 0.10.1 +- Seek and play now works with milliseconds instead of second (thanks, @catoldcui and @erickzanardo) + +## audioplayers 0.10.0 +- Added a low latency api for android (thanks, @feroult) + +## audioplayers 0.9.0 +- Improved callbacks using Streams to allow for multiple subscibers (thanks, @LucasCLuk) +- Update uuid version to 2.0.0 (thanks, @BeMacized) + +## audioplayers 0.8.2 +- Update path_provider version (thanks, @apiraino) + +## audioplayers 0.8.1 +- Fix for duration when playing a stream +- Added respectSilence flag in audioplayers, or isNotification for play methos in audio_cache + False by default, to use player for local notification. Silent when device is in silent mode. + +## audioplayers 0.8.0 +- Allow setting seek position in play function (thanks @rob-patchett) +- Get duration from the underlaying asset instead of from AVPlayerItem (thanks @andressade) +- Adding player state (thanks @renancaraujo) +- Set the audio session to active (thanks @benwicks) +- Delay seek operations on Android until player is ready (thanks @jeffmikels) + +## audioplayers 0.7.8 +- Fix bug regarding name clash with other plugins (thanks @imtaehyun) + +## audioplayers 0.7.7 +- Fix bug when using nested files with audio cache (thanks @hotstu for reporting and @eclewlow for fixing) + +## audioplayers 0.7.6 +- Fix the nefarious bug of 'sound only playing through headphones' (thanks so much, @tsun424) + +## audioplayers 0.7.5 +- Fix SDK constraint for Dart 2.1 (thanks @snoofer and @sroddy) + +## audioplayers 0.7.4 +- Some more fixes to work without errors with Dart 2 stronger types + +## audioplayers 0.7.3 +- Support Android SDK 16-20 (thanks, @sroddy) +- Avoid restarting a looping player if is stopped (thanks, @sroddy) + +## audioplayers 0.7.2 +- Bug fixes for iOS + +## audioplayers 0.7.1 +- Formatting + +## audioplayers 0.7.0 + +- Improved lifecycle handling for android +- Big performance boots +- Allows for finer control of releasing (with setReleaseMode, setUrl, resume, release) +- Allows for setting the volume at any time (with setVolume) +- Added LOOP as a ReleaseMode options, making it significantly faster +- Some other refactorings + +## audioplayers 0.6.0 + +- Major Refactoring! +- Renaming everything to audioplayers (mind the s) +- Better logging +- Added AudioCache (imported from Flame) +- Adding tests! +- Adding better example +- Greatly improving README +- Lots of other minor tweaks + +## audioplayers 0.5.2 + +- don't call the onClomplete hook when you manually stop the audio + +## audioplayers 0.5.1 + +- fix for dart 2 (thanks to @efortuna) + +## audioplayers 0.5.0 + +- improves Android performance by not calling `prepare` on the main thread + +## audioplayers 0.4.1 + +- fix `seek` for iOS + +## audioplayers 0.4.0 + +- volume controls + +## audioplayers 0.3.0 + +- working on iOS (thanks @feroult <3) + +## audioplayers 0.2.0 + +- adding disable log option + +## audioplayers 0.1.0 + +- support for multiple audios simultaneously + +## 0.2.0 + +- support for local files + +## 0.1.0 + +- update to the current Plugin API +- move to https://github.com/rxlabz/audioplayer + +## 0.0.2 + +Separated handlers for position, duration, completion and errors + +- setDurationHandler(TimeChangeHandler handler) +- setPositionHandler(TimeChangeHandler handler) +- setCompletionHandler(VoidCallback callback) +- setErrorHandler(ErrorHandler handler) + +- new typedef +```dart +typedef void TimeChangeHandler(Duration duration); +typedef void ErrorHandler(String message); +``` + +## 0.0.1 + +- first POC : + - methods : play, pause, stop + - a globalHandler for position, duration, completion and errors diff --git a/packages/audioplayers_android/LICENSE b/packages/audioplayers_android/LICENSE new file mode 100644 index 000000000..1a581b05c --- /dev/null +++ b/packages/audioplayers_android/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audioplayers_android/README.md b/packages/audioplayers_android/README.md new file mode 100644 index 000000000..5a4aba4e6 --- /dev/null +++ b/packages/audioplayers_android/README.md @@ -0,0 +1,23 @@ +

+ + AudioPlayers + +

+ +--- + +# audioplayers_android +

+ + + + +

+ +The Android implementation of [`audioplayers`](https://pub.dev/packages/audioplayers). + +## Usage + +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can simply use `audioplayers` normally. +This package will be automatically included in your app when you do, so you do not need to add it to your `pubspec.yaml`. diff --git a/packages/audioplayers_android/analysis_options.yaml b/packages/audioplayers_android/analysis_options.yaml new file mode 100644 index 000000000..85732fa02 --- /dev/null +++ b/packages/audioplayers_android/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/audioplayers/android/.gitignore b/packages/audioplayers_android/android/.gitignore similarity index 100% rename from packages/audioplayers/android/.gitignore rename to packages/audioplayers_android/android/.gitignore diff --git a/packages/audioplayers/android/build.gradle b/packages/audioplayers_android/android/build.gradle similarity index 53% rename from packages/audioplayers/android/build.gradle rename to packages/audioplayers_android/android/build.gradle index 9ac75cf12..6651c6ce2 100644 --- a/packages/audioplayers/android/build.gradle +++ b/packages/audioplayers_android/android/build.gradle @@ -2,14 +2,16 @@ group 'xyz.luan.audioplayers' version '1.0-SNAPSHOT' buildscript { - ext.kotlin_version = '1.4.32' + ext.kotlin_version = '1.7.10' + ext.coroutines_version = '1.6.4' + repositories { google() mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.2.2' + classpath 'com.android.tools.build:gradle:7.3.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath "de.mannodermaus.gradle.plugins:android-junit5:1.7.1.1" } @@ -27,17 +29,32 @@ apply plugin: 'kotlin-android' apply plugin: 'de.mannodermaus.android-junit5' android { - compileSdkVersion 28 + compileSdk 35 + + // Conditional for compatibility with AGP <4.2. + if (project.android.hasProperty('namespace')) { + namespace 'xyz.luan.audioplayers' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } sourceSets { main.java.srcDirs += 'src/main/kotlin' } defaultConfig { - minSdkVersion 16 + minSdkVersion 19 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } - lintOptions { + + lint { disable 'InvalidPackage' } } @@ -51,10 +68,12 @@ allprojects { } dependencies { - implementation "androidx.core:core-ktx:1.6.0" + implementation "androidx.core:core-ktx:1.9.0" // Do not pump unless dropping support for AGP7 implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" - testImplementation "org.junit.jupiter:junit-jupiter:5.8.2" - testImplementation "org.assertj:assertj-core:3.21.0" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" + testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0' + testImplementation 'org.assertj:assertj-core:3.23.1' } repositories { mavenCentral() diff --git a/packages/audioplayers/android/gradle.properties b/packages/audioplayers_android/android/gradle.properties similarity index 100% rename from packages/audioplayers/android/gradle.properties rename to packages/audioplayers_android/android/gradle.properties diff --git a/packages/audioplayers_android/android/settings.gradle b/packages/audioplayers_android/android/settings.gradle new file mode 100644 index 000000000..096aeb19c --- /dev/null +++ b/packages/audioplayers_android/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'audioplayers_android' diff --git a/packages/audioplayers_android/android/src/main/AndroidManifest.xml b/packages/audioplayers_android/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0f77d9bf9 --- /dev/null +++ b/packages/audioplayers_android/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioContextAndroid.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioContextAndroid.kt new file mode 100644 index 000000000..eabbb50b9 --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioContextAndroid.kt @@ -0,0 +1,69 @@ +package xyz.luan.audioplayers + +import android.annotation.SuppressLint +import android.media.AudioAttributes +import android.media.AudioAttributes.Builder +import android.media.AudioAttributes.CONTENT_TYPE_MUSIC +import android.media.AudioAttributes.USAGE_MEDIA +import android.media.AudioAttributes.USAGE_NOTIFICATION_RINGTONE +import android.media.AudioAttributes.USAGE_VOICE_COMMUNICATION +import android.media.AudioManager +import android.media.MediaPlayer +import android.os.Build +import androidx.annotation.RequiresApi +import java.util.* + +data class AudioContextAndroid( + val isSpeakerphoneOn: Boolean, + val stayAwake: Boolean, + val contentType: Int, + val usageType: Int, + val audioFocus: Int, + val audioMode: Int, +) { + @SuppressLint("InlinedApi") // we are just using numerical constants + constructor() : this( + isSpeakerphoneOn = false, + stayAwake = false, + contentType = CONTENT_TYPE_MUSIC, + usageType = USAGE_MEDIA, + audioFocus = AudioManager.AUDIOFOCUS_GAIN, + audioMode = AudioManager.MODE_NORMAL, + ) + + fun setAttributesOnPlayer(player: MediaPlayer) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + player.setAudioAttributes(buildAttributes()) + } else { + @Suppress("DEPRECATION") + player.setAudioStreamType(getStreamType()) + } + } + + @RequiresApi(Build.VERSION_CODES.LOLLIPOP) + fun buildAttributes(): AudioAttributes { + return Builder() + .setUsage(usageType) + .setContentType(contentType) + .build() + } + + @Deprecated("This is used for Android older than LOLLIPOP", replaceWith = ReplaceWith("buildAttributes")) + private fun getStreamType(): Int { + return when (usageType) { + USAGE_VOICE_COMMUNICATION -> AudioManager.STREAM_VOICE_CALL + USAGE_NOTIFICATION_RINGTONE -> AudioManager.STREAM_RING + else -> AudioManager.STREAM_MUSIC + } + } + + override fun hashCode() = Objects.hash(isSpeakerphoneOn, stayAwake, contentType, usageType, audioFocus, audioMode) + + override fun equals(other: Any?) = (other is AudioContextAndroid) && + isSpeakerphoneOn == other.isSpeakerphoneOn && + stayAwake == other.stayAwake && + contentType == other.contentType && + usageType == other.usageType && + audioFocus == other.audioFocus && + audioMode == other.audioMode +} diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt new file mode 100644 index 000000000..7bf894952 --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt @@ -0,0 +1,306 @@ +package xyz.luan.audioplayers + +import android.content.Context +import android.media.AudioManager +import android.os.Build +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import xyz.luan.audioplayers.player.SoundPoolManager +import xyz.luan.audioplayers.player.WrappedPlayer +import xyz.luan.audioplayers.source.BytesSource +import xyz.luan.audioplayers.source.UrlSource +import java.io.FileNotFoundException +import java.util.concurrent.ConcurrentHashMap + +typealias FlutterHandler = (call: MethodCall, response: MethodChannel.Result) -> Unit + +class AudioplayersPlugin : FlutterPlugin { + private lateinit var methods: MethodChannel + private lateinit var globalMethods: MethodChannel + private lateinit var globalEvents: EventHandler + private lateinit var context: Context + private lateinit var binaryMessenger: BinaryMessenger + private lateinit var soundPoolManager: SoundPoolManager + + private val players = ConcurrentHashMap() + private var defaultAudioContext = AudioContextAndroid() + + override fun onAttachedToEngine(binding: FlutterPluginBinding) { + context = binding.applicationContext + binaryMessenger = binding.binaryMessenger + soundPoolManager = SoundPoolManager(this) + methods = MethodChannel(binding.binaryMessenger, "xyz.luan/audioplayers") + methods.setMethodCallHandler { call, response -> safeCall(call, response, ::methodHandler) } + globalMethods = MethodChannel(binding.binaryMessenger, "xyz.luan/audioplayers.global") + globalMethods.setMethodCallHandler { call, response -> safeCall(call, response, ::globalMethodHandler) } + globalEvents = EventHandler(EventChannel(binding.binaryMessenger, "xyz.luan/audioplayers.global/events")) + } + + override fun onDetachedFromEngine(binding: FlutterPluginBinding) { + players.values.forEach { it.dispose() } + players.clear() + soundPoolManager.dispose() + globalEvents.dispose() + } + + private fun safeCall( + call: MethodCall, + response: MethodChannel.Result, + handler: FlutterHandler, + ) { + try { + handler(call, response) + } catch (e: Throwable) { + response.error("Unexpected AndroidAudioError", e.message, e) + } + } + + private fun globalMethodHandler(call: MethodCall, response: MethodChannel.Result) { + when (call.method) { + "init" -> { + players.values.forEach { it.dispose() } + players.clear() + } + + "setAudioContext" -> { + val audioManager = getAudioManager() + audioManager.mode = defaultAudioContext.audioMode + audioManager.isSpeakerphoneOn = defaultAudioContext.isSpeakerphoneOn + + defaultAudioContext = call.audioContext() + } + + "emitLog" -> { + val message = call.argument("message") ?: error("message is required") + handleGlobalLog(message) + } + + "emitError" -> { + val code = call.argument("code") ?: error("code is required") + val message = call.argument("message") ?: error("message is required") + handleGlobalError(code, message, null) + } + + else -> { + response.notImplemented() + return + } + } + + response.success(1) + } + + private fun methodHandler(call: MethodCall, response: MethodChannel.Result) { + val playerId = call.argument("playerId") ?: return + if (call.method == "create") { + val eventHandler = EventHandler(EventChannel(binaryMessenger, "xyz.luan/audioplayers/events/$playerId")) + players[playerId] = WrappedPlayer(this, eventHandler, defaultAudioContext.copy(), soundPoolManager) + response.success(1) + return + } + val player = getPlayer(playerId) + try { + when (call.method) { + "setSourceUrl" -> { + val url = call.argument("url") ?: error("url is required") + val isLocal = call.argument("isLocal") ?: false + try { + player.source = UrlSource(url, isLocal) + } catch (e: FileNotFoundException) { + response.error( + "AndroidAudioError", + "Failed to set source. For troubleshooting, see: " + + "https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md", + e, + ) + return + } + } + + "setSourceBytes" -> { + val bytes = call.argument("bytes") ?: error("bytes are required") + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + error("BytesSource is not supported on Android <= M") + } + player.source = BytesSource(bytes) + } + + "resume" -> player.play() + "pause" -> player.pause() + "stop" -> player.stop() + "release" -> player.release() + "seek" -> { + val position = call.argument("position") ?: error("position is required") + player.seek(position) + } + + "setVolume" -> { + val volume = call.argument("volume") ?: error("volume is required") + player.volume = volume.toFloat() + } + + "setBalance" -> { + val balance = call.argument("balance") ?: error("balance is required") + player.balance = balance.toFloat() + } + + "setPlaybackRate" -> { + val rate = call.argument("playbackRate") ?: error("playbackRate is required") + player.rate = rate.toFloat() + } + + "getDuration" -> { + response.success(player.getDuration()) + return + } + + "getCurrentPosition" -> { + response.success(player.getCurrentPosition()) + return + } + + "setReleaseMode" -> { + val releaseMode = call.enumArgument("releaseMode") ?: error("releaseMode is required") + player.releaseMode = releaseMode + } + + "setPlayerMode" -> { + val playerMode = call.enumArgument("playerMode") ?: error("playerMode is required") + player.playerMode = playerMode + } + + "setAudioContext" -> { + val audioContext = call.audioContext() + player.updateAudioContext(audioContext) + } + + "emitLog" -> { + val message = call.argument("message") ?: error("message is required") + player.handleLog(message) + } + + "emitError" -> { + val code = call.argument("code") ?: error("code is required") + val message = call.argument("message") ?: error("message is required") + player.handleError(code, message, null) + } + + "dispose" -> { + player.dispose() + players.remove(playerId) + } + + else -> { + response.notImplemented() + return + } + } + response.success(1) + } catch (e: Exception) { + response.error("AndroidAudioError", e.message, e) + } + } + + private fun getPlayer(playerId: String): WrappedPlayer { + return players[playerId] ?: error("Player has not yet been created or has already been disposed.") + } + + fun getApplicationContext(): Context { + return context.applicationContext + } + + fun getAudioManager(): AudioManager { + return context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + } + + fun handleDuration(player: WrappedPlayer) { + player.eventHandler.success( + "audio.onDuration", + hashMapOf("value" to (player.getDuration() ?: 0)), + ) + } + + fun handleComplete(player: WrappedPlayer) { + player.eventHandler.success("audio.onComplete") + } + + fun handlePrepared(player: WrappedPlayer, isPrepared: Boolean) { + player.eventHandler.success("audio.onPrepared", hashMapOf("value" to isPrepared)) + } + + fun handleLog(player: WrappedPlayer, message: String) { + player.eventHandler.success("audio.onLog", hashMapOf("value" to message)) + } + + fun handleGlobalLog(message: String) { + globalEvents.success("audio.onLog", hashMapOf("value" to message)) + } + + fun handleError(player: WrappedPlayer, errorCode: String?, errorMessage: String?, errorDetails: Any?) { + player.eventHandler.error(errorCode, errorMessage, errorDetails) + } + + fun handleGlobalError(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + globalEvents.error(errorCode, errorMessage, errorDetails) + } + + fun handleSeekComplete(player: WrappedPlayer) { + player.eventHandler.success("audio.onSeekComplete") + } +} + +private inline fun > MethodCall.enumArgument(name: String): T? { + val enumName = argument(name) ?: return null + return enumValueOf(enumName.split('.').last().toConstantCase()) +} + +fun String.toConstantCase(): String { + return replace(Regex("(.)(\\p{Upper})"), "$1_$2").replace(Regex("(.) (.)"), "$1_$2").uppercase() +} + +private fun MethodCall.audioContext(): AudioContextAndroid { + return AudioContextAndroid( + isSpeakerphoneOn = argument("isSpeakerphoneOn") ?: error("isSpeakerphoneOn is required"), + stayAwake = argument("stayAwake") ?: error("stayAwake is required"), + contentType = argument("contentType") ?: error("contentType is required"), + usageType = argument("usageType") ?: error("usageType is required"), + audioFocus = argument("audioFocus") ?: error("audioFocus is required"), + audioMode = argument("audioMode") ?: error("audioMode is required"), + ) +} + +class EventHandler(private val eventChannel: EventChannel) : EventChannel.StreamHandler { + private var eventSink: EventChannel.EventSink? = null + + init { + eventChannel.setStreamHandler(this) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + fun success(method: String, arguments: Map = HashMap()) { + eventSink?.success(arguments + Pair("event", method)) + } + + fun error(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + eventSink?.error(errorCode, errorMessage, errorDetails) + } + + fun dispose() { + eventSink?.let { + it.endOfStream() + onCancel(null) + } + eventChannel.setStreamHandler(null) + } +} diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/PlayerMode.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/PlayerMode.kt new file mode 100644 index 000000000..d2a249e29 --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/PlayerMode.kt @@ -0,0 +1,5 @@ +package xyz.luan.audioplayers + +enum class PlayerMode { + MEDIA_PLAYER, LOW_LATENCY +} diff --git a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt similarity index 97% rename from packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt rename to packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt index 0bc2fcaea..cadb54b37 100644 --- a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt @@ -2,4 +2,4 @@ package xyz.luan.audioplayers enum class ReleaseMode { RELEASE, LOOP, STOP -} \ No newline at end of file +} diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/FocusManager.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/FocusManager.kt new file mode 100644 index 000000000..8463306bd --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/FocusManager.kt @@ -0,0 +1,155 @@ +package xyz.luan.audioplayers.player + +import android.media.AudioFocusRequest +import android.media.AudioManager +import android.os.Build +import androidx.annotation.RequiresApi +import xyz.luan.audioplayers.AudioContextAndroid + +abstract class FocusManager { + abstract val player: WrappedPlayer + abstract val onGranted: () -> Unit + abstract val onLoss: (isTransient: Boolean) -> Unit + abstract var context: AudioContextAndroid + + companion object { + fun create( + player: WrappedPlayer, + onGranted: () -> Unit, + onLoss: (isTransient: Boolean) -> Unit, + ): FocusManager { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + ModernFocusManager(player, onGranted, onLoss) + } else { + LegacyFocusManager(player, onGranted, onLoss) + } + } + } + + protected abstract fun hasAudioFocusRequest(): Boolean + + protected abstract fun updateAudioFocusRequest() + + protected val audioManager: AudioManager + get() = player.audioManager + + fun maybeRequestAudioFocus() { + if (context != player.context) { + context = player.context + updateAudioFocusRequest() + } + if (hasAudioFocusRequest()) { + requestAudioFocus() + } else { + // Grant without requesting focus, if it is AudioManager.AUDIOFOCUS_NONE + onGranted() + } + } + + protected abstract fun requestAudioFocus() + + abstract fun handleStop() + + protected fun handleFocusResult(result: Int) { + when (result) { + AudioManager.AUDIOFOCUS_REQUEST_GRANTED -> { + onGranted() + } + + AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> { + onLoss(true) + } + + AudioManager.AUDIOFOCUS_LOSS -> { + onLoss(false) + } + } + // Keep playing source on `AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK` as sound is ducked. + } +} + +private class LegacyFocusManager( + override val player: WrappedPlayer, + override val onGranted: () -> Unit, + override val onLoss: (isTransient: Boolean) -> Unit, +) : FocusManager() { + override var context: AudioContextAndroid = player.context + + // Deprecated variant of listening to focus changes + private var audioFocusChangeListener: AudioManager.OnAudioFocusChangeListener? = null + + init { + updateAudioFocusRequest() + } + + override fun hasAudioFocusRequest(): Boolean { + return audioFocusChangeListener != null + } + + override fun updateAudioFocusRequest() { + audioFocusChangeListener = if (context.audioFocus == AudioManager.AUDIOFOCUS_NONE) { + // Mix sound with others + null + } else { + AudioManager.OnAudioFocusChangeListener { handleFocusResult(it) } + } + } + + override fun handleStop() { + if (hasAudioFocusRequest()) { + @Suppress("DEPRECATION") + audioManager.abandonAudioFocus(audioFocusChangeListener) + } + } + + override fun requestAudioFocus() { + @Suppress("DEPRECATION") + val result = audioManager.requestAudioFocus( + audioFocusChangeListener, + AudioManager.STREAM_MUSIC, + context.audioFocus, + ) + handleFocusResult(result) + } +} + +@RequiresApi(Build.VERSION_CODES.O) +private class ModernFocusManager( + override val player: WrappedPlayer, + override val onGranted: () -> Unit, + override val onLoss: (isTransient: Boolean) -> Unit, +) : FocusManager() { + override var context: AudioContextAndroid = player.context + + // Listen also for focus changes, e.g. if interrupt playing with a phone call and resume afterward. + private var audioFocusRequest: AudioFocusRequest? = null + + init { + updateAudioFocusRequest() + } + + override fun hasAudioFocusRequest(): Boolean { + return audioFocusRequest != null + } + + override fun updateAudioFocusRequest() { + audioFocusRequest = if (context.audioFocus == AudioManager.AUDIOFOCUS_NONE) { + // Mix sound with others + null + } else { + AudioFocusRequest.Builder(context.audioFocus).setAudioAttributes(context.buildAttributes()) + .setOnAudioFocusChangeListener { handleFocusResult(it) }.build() + } + } + + override fun handleStop() { + if (hasAudioFocusRequest()) { + audioFocusRequest?.let { audioManager.abandonAudioFocusRequest(it) } + } + } + + override fun requestAudioFocus() { + val result = audioManager.requestAudioFocus(audioFocusRequest!!) + handleFocusResult(result) + } +} diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/MediaPlayerWrapper.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/MediaPlayerWrapper.kt new file mode 100644 index 000000000..1e2b3b003 --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/MediaPlayerWrapper.kt @@ -0,0 +1,99 @@ +package xyz.luan.audioplayers.player + +import android.media.MediaPlayer +import android.os.Build +import android.os.PowerManager +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.source.Source + +class MediaPlayerWrapper( + private val wrappedPlayer: WrappedPlayer, +) : PlayerWrapper { + private val mediaPlayer = createMediaPlayer(wrappedPlayer) + + private fun createMediaPlayer(wrappedPlayer: WrappedPlayer): MediaPlayer { + val mediaPlayer = MediaPlayer().apply { + setOnPreparedListener { wrappedPlayer.onPrepared() } + setOnCompletionListener { wrappedPlayer.onCompletion() } + setOnSeekCompleteListener { wrappedPlayer.onSeekComplete() } + setOnErrorListener { _, what, extra -> wrappedPlayer.onError(what, extra) } + setOnBufferingUpdateListener { _, percent -> wrappedPlayer.onBuffering(percent) } + } + wrappedPlayer.context.setAttributesOnPlayer(mediaPlayer) + return mediaPlayer + } + + override fun getDuration(): Int? { + // media player returns -1 if the duration is unknown + return mediaPlayer.duration.takeUnless { it == -1 } + } + + override fun getCurrentPosition(): Int { + return mediaPlayer.currentPosition + } + + override fun setVolume(leftVolume: Float, rightVolume: Float) { + mediaPlayer.setVolume(leftVolume, rightVolume) + } + + override fun setRate(rate: Float) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(rate) + } else if (rate == 1.0f) { + mediaPlayer.start() + } else { + error("Changing the playback rate is only available for Android M/23+ or using LOW_LATENCY mode.") + } + } + + override fun setSource(source: Source) { + reset() + source.setForMediaPlayer(mediaPlayer) + } + + override fun setLooping(looping: Boolean) { + mediaPlayer.isLooping = looping + } + + override fun start() { + // Setting playback rate instead of mediaPlayer.start(). + setRate(wrappedPlayer.rate) + } + + override fun pause() { + mediaPlayer.pause() + } + + override fun stop() { + mediaPlayer.stop() + } + + override fun release() { + mediaPlayer.reset() + mediaPlayer.release() + } + + override fun seekTo(position: Int) { + mediaPlayer.seekTo(position) + } + + override fun updateContext(context: AudioContextAndroid) { + context.setAttributesOnPlayer(mediaPlayer) + if (context.stayAwake) { + mediaPlayer.setWakeMode(wrappedPlayer.applicationContext, PowerManager.PARTIAL_WAKE_LOCK) + } + } + + override fun prepare() { + mediaPlayer.prepareAsync() + } + + override fun reset() { + mediaPlayer.reset() + } + + override fun isLiveStream(): Boolean { + val duration = getDuration() + return duration == null || duration == 0 + } +} diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/PlayerWrapper.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/PlayerWrapper.kt new file mode 100644 index 000000000..f6ad22bed --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/PlayerWrapper.kt @@ -0,0 +1,25 @@ +package xyz.luan.audioplayers.player + +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.source.Source + +interface PlayerWrapper { + fun getDuration(): Int? + fun getCurrentPosition(): Int? + fun isLiveStream(): Boolean + + fun start() + fun pause() + fun stop() + fun seekTo(position: Int) + + fun setVolume(leftVolume: Float, rightVolume: Float) + fun setRate(rate: Float) + fun setLooping(looping: Boolean) + fun updateContext(context: AudioContextAndroid) + fun setSource(source: Source) + + fun prepare() + fun release() + fun reset() +} diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/SoundPoolPlayer.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/SoundPoolPlayer.kt new file mode 100644 index 000000000..ff4403546 --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/SoundPoolPlayer.kt @@ -0,0 +1,307 @@ +package xyz.luan.audioplayers.player + +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.SoundPool +import android.os.Build +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.AudioplayersPlugin +import xyz.luan.audioplayers.source.Source +import xyz.luan.audioplayers.source.UrlSource +import java.util.Collections.synchronizedMap + +/** Value should not exceed 32 */ +// TODO(luan): make this configurable +private const val MAX_STREAMS = 32 + +class SoundPoolPlayer( + val wrappedPlayer: WrappedPlayer, + private val soundPoolManager: SoundPoolManager, +) : PlayerWrapper { + private val mainScope = CoroutineScope(Dispatchers.Main) + + /** The id of the sound of source which will be played */ + var soundId: Int? = null + + /** The id of the stream / player */ + private var streamId: Int? = null + + private var audioContext = wrappedPlayer.context + set(value) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + // AudioAttributes are compared by its property values. + if (field.buildAttributes() != value.buildAttributes()) { + release() + soundPoolManager.createSoundPoolWrapper(MAX_STREAMS, value) + soundPoolWrapper = soundPoolManager.getSoundPoolWrapper(value) + ?: error("Could not create SoundPool $value") + } + } + field = value + } + + private var soundPoolWrapper: SoundPoolWrapper + + private val soundPool: SoundPool + get() = soundPoolWrapper.soundPool + + init { + soundPoolManager.createSoundPoolWrapper(MAX_STREAMS, audioContext) + soundPoolWrapper = soundPoolManager.getSoundPoolWrapper(audioContext) + ?: error("Could not create SoundPool $audioContext") + } + + override fun stop() { + streamId?.let { + soundPool.stop(it) + streamId = null + } + } + + override fun release() { + stop() + val soundId = this.soundId ?: return + val urlSource = this.urlSource ?: return + + synchronized(soundPoolWrapper.urlToPlayers) { + val playersForSoundId = soundPoolWrapper.urlToPlayers[urlSource] ?: return + if (playersForSoundId.singleOrNull() === this) { + soundPoolWrapper.urlToPlayers.remove(urlSource) + soundPool.unload(soundId) + soundPoolWrapper.soundIdToPlayer.remove(soundId) + wrappedPlayer.handleLog("unloaded soundId $soundId") + } else { + // This is not the last player using the soundId, just remove it from the list. + playersForSoundId.remove(this) + } + this.soundId = null + this.urlSource = null + } + } + + override fun pause() { + streamId?.let { soundPool.pause(it) } + } + + override fun updateContext(context: AudioContextAndroid) { + audioContext = context + } + + override fun setSource(source: Source) { + source.setForSoundPool(this) + } + + var urlSource: UrlSource? = null + set(value) { + if (value != null) { + synchronized(soundPoolWrapper.urlToPlayers) { + val urlPlayers = soundPoolWrapper.urlToPlayers.getOrPut(value) { mutableListOf() } + val originalPlayer = urlPlayers.firstOrNull() + + if (originalPlayer != null) { + // Sound has already been loaded - reuse the soundId. + val prepared = originalPlayer.wrappedPlayer.prepared + wrappedPlayer.prepared = prepared + soundId = originalPlayer.soundId + wrappedPlayer.handleLog("Reusing soundId $soundId for $value is prepared=$prepared $this") + } else { + // First one for this URL - load it. + val start = System.currentTimeMillis() + + wrappedPlayer.prepared = false + val soundPoolPlayer = this + wrappedPlayer.handleLog("Fetching actual URL for $value") + + // Need to load sound on another thread than main to avoid `NetworkOnMainThreadException` + mainScope.launch(Dispatchers.IO) { + val actualUrl = value.getAudioPathForSoundPool() + // Run on main thread again + mainScope.launch(Dispatchers.Main) { + wrappedPlayer.handleLog("Now loading $actualUrl") + val intSoundId = soundPool.load(actualUrl, 1) + soundPoolWrapper.soundIdToPlayer[intSoundId] = soundPoolPlayer + soundId = intSoundId + + wrappedPlayer.handleLog( + "time to call load() for $value: " + + "${System.currentTimeMillis() - start} player=$this", + ) + } + } + } + urlPlayers.add(this) + } + } + field = value + } + + override fun setVolume(leftVolume: Float, rightVolume: Float) { + streamId?.let { soundPool.setVolume(it, leftVolume, rightVolume) } + } + + override fun setRate(rate: Float) { + streamId?.let { soundPool.setRate(it, rate) } + } + + override fun setLooping(looping: Boolean) { + streamId?.let { soundPool.setLoop(it, looping.loopModeInteger()) } + } + + // Cannot get duration for Sound Pool + override fun getDuration() = null + + // Cannot get current position for Sound Pool + override fun getCurrentPosition() = null + + override fun seekTo(position: Int) { + if (position == 0) { + streamId?.let { + stop() + if (wrappedPlayer.playing) { + soundPool.resume(it) + } + } + } else { + unsupportedOperation("seek") + } + } + + override fun start() { + val streamId = streamId + val soundId = soundId + + if (streamId != null) { + soundPool.resume(streamId) + } else if (soundId != null) { + this.streamId = soundPool.play( + soundId, + wrappedPlayer.volume, + wrappedPlayer.volume, + 0, + wrappedPlayer.isLooping.loopModeInteger(), + wrappedPlayer.rate, + ) + } + } + + override fun prepare() { + // sound pool automatically prepares when source URL is set + } + + override fun reset() { + // TODO(luan): what do I do here? + } + + override fun isLiveStream() = false + + /** Integer representation of the loop mode used by Android */ + private fun Boolean.loopModeInteger(): Int = if (this) -1 else 0 + + private fun unsupportedOperation(message: String): Nothing { + throw UnsupportedOperationException("LOW_LATENCY mode does not support: $message") + } +} + +class SoundPoolManager( + private val ref: AudioplayersPlugin, +) { + + // Only needed for legacy apps with SDK < 21 + private var legacySoundPoolWrapper: SoundPoolWrapper? = null + + /** + * Lazy store one [SoundPoolWrapper] for each [AudioAttributes] configuration. + * [AudioAttributes] are compared by its property values, so it can be used as key. + */ + private val soundPoolWrappers = HashMap() + + /** + * Create a SoundPoolWrapper with the given [maxStreams] and the according [audioContext] and save it to be + * globally accessible for every player. + * + * @param maxStreams the maximum number of simultaneous streams for this + * SoundPool object, see [SoundPool.Builder.setMaxStreams] + */ + fun createSoundPoolWrapper(maxStreams: Int, audioContext: AudioContextAndroid) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val attrs = audioContext.buildAttributes() + if (!soundPoolWrappers.containsKey(attrs)) { + val soundPool = SoundPool.Builder() + .setAudioAttributes(attrs) + .setMaxStreams(maxStreams) + .build() + ref.handleGlobalLog("Create SoundPool with $attrs") + val soundPoolWrapper = SoundPoolWrapper(soundPool) + soundPoolWrapper.soundPool.setOnLoadCompleteListener { _, sampleId, _ -> + ref.handleGlobalLog("Loaded $sampleId") + val loadingPlayer = soundPoolWrapper.soundIdToPlayer[sampleId] + val urlSource = loadingPlayer?.urlSource + if (urlSource != null) { + soundPoolWrapper.soundIdToPlayer.remove(loadingPlayer.soundId) + // Now mark all players using this sound as not loading and start them if necessary + synchronized(soundPoolWrapper.urlToPlayers) { + val urlPlayers = soundPoolWrapper.urlToPlayers[urlSource] ?: listOf() + for (player in urlPlayers) { + player.wrappedPlayer.handleLog("Marking $player as loaded") + player.wrappedPlayer.prepared = true + if (player.wrappedPlayer.playing) { + player.wrappedPlayer.handleLog("Delayed start of $player") + player.start() + } + } + } + } + } + soundPoolWrappers[attrs] = soundPoolWrapper + } + } else if (legacySoundPoolWrapper == null) { + @Suppress("DEPRECATION") + val soundPool = SoundPool(maxStreams, AudioManager.STREAM_MUSIC, 0) + ref.handleGlobalLog("Create legacy SoundPool") + legacySoundPoolWrapper = SoundPoolWrapper(soundPool) + } + } + + /** + * Get the [SoundPoolWrapper] with the given [audioContext]. + */ + fun getSoundPoolWrapper(audioContext: AudioContextAndroid): SoundPoolWrapper? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + val attrs = audioContext.buildAttributes() + soundPoolWrappers[attrs] + } else { + legacySoundPoolWrapper + } + } + + fun dispose() { + for (soundPoolEntry in soundPoolWrappers) { + soundPoolEntry.value.dispose() + } + soundPoolWrappers.clear() + } +} + +class SoundPoolWrapper(val soundPool: SoundPool) { + + /** For the onLoadComplete listener, track which sound id is associated with which player. An entry only exists until + * it has been loaded. + */ + val soundIdToPlayer: MutableMap = synchronizedMap(mutableMapOf()) + + /** This is to keep track of the players which share the same sound id, referenced by url. When a player release()s, it + * is removed from the associated player list. The last player to be removed actually unloads() the sound id and then + * the url is removed from this map. + */ + val urlToPlayers: MutableMap> = + synchronizedMap(mutableMapOf>()) + + fun dispose() { + soundPool.release() + soundIdToPlayer.clear() + urlToPlayers.clear() + } +} diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt new file mode 100644 index 000000000..8a7d9c459 --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt @@ -0,0 +1,395 @@ +package xyz.luan.audioplayers.player + +import android.content.Context +import android.media.AudioManager +import android.media.MediaPlayer +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.AudioplayersPlugin +import xyz.luan.audioplayers.EventHandler +import xyz.luan.audioplayers.PlayerMode +import xyz.luan.audioplayers.PlayerMode.LOW_LATENCY +import xyz.luan.audioplayers.PlayerMode.MEDIA_PLAYER +import xyz.luan.audioplayers.ReleaseMode +import xyz.luan.audioplayers.source.Source +import kotlin.math.min + +// For some reason this cannot be accessed from MediaPlayer.MEDIA_ERROR_SYSTEM +private const val MEDIA_ERROR_SYSTEM = -2147483648 + +class WrappedPlayer internal constructor( + private val ref: AudioplayersPlugin, + val eventHandler: EventHandler, + var context: AudioContextAndroid, + private val soundPoolManager: SoundPoolManager, +) { + private var player: PlayerWrapper? = null + + var source: Source? = null + set(value) { + if (field != value) { + if (value != null) { + val player = getOrCreatePlayer() + player.setSource(value) + player.configAndPrepare() + } else { + released = true + prepared = false + playing = false + player?.release() + } + field = value + } else { + ref.handlePrepared(this, true) + } + } + + var volume = 1.0f + set(value) { + if (field != value) { + field = value + if (!released) { + player?.setVolumeAndBalance(value, balance) + } + } + } + + var balance = 0.0f + set(value) { + if (field != value) { + field = value + if (!released) { + player?.setVolumeAndBalance(volume, value) + } + } + } + + var rate = 1.0f + set(value) { + if (field != value) { + field = value + if (playing) { + player?.setRate(value) + } + } + } + + var releaseMode = ReleaseMode.RELEASE + set(value) { + if (field != value) { + field = value + if (!released) { + player?.setLooping(isLooping) + } + } + } + + val isLooping: Boolean + get() = releaseMode == ReleaseMode.LOOP + + var playerMode: PlayerMode = MEDIA_PLAYER + set(value) { + if (field != value) { + field = value + + // if the player exists, we need to re-create it from scratch; + // this will probably cause music to pause for a second + player?.let { + shouldSeekTo = maybeGetCurrentPosition() + prepared = false + it.release() + } + initPlayer() + } + } + + var released = true + + var prepared: Boolean = false + set(value) { + if (field != value) { + field = value + ref.handlePrepared(this, value) + } + } + + var playing = false + var shouldSeekTo = -1 + + private val focusManager = FocusManager.create( + this, + onGranted = { + // Check if in playing state, as the focus can also be gained e.g. after a phone call, even if not playing. + if (playing) { + player?.start() + } + }, + onLoss = { isTransient -> + if (isTransient) { + // Do not check or set playing state, as the state should be recovered after granting focus again. + player?.pause() + } else { + // Audio focus won't be recovered + pause() + } + }, + ) + + private fun maybeGetCurrentPosition(): Int { + // for Sound Pool, we can't get current position, so we just start over + return runCatching { player?.getCurrentPosition().takeUnless { it == 0 } }.getOrNull() ?: -1 + } + + private fun getOrCreatePlayer(): PlayerWrapper { + val currentPlayer = player + return if (released || currentPlayer == null) { + createPlayer().also { + player = it + released = false + } + } else if (prepared) { + currentPlayer.also { + it.reset() + prepared = false + } + } else { + currentPlayer + } + } + + fun updateAudioContext(audioContext: AudioContextAndroid) { + if (context == audioContext) { + return + } + if (context.audioFocus != AudioManager.AUDIOFOCUS_NONE && + audioContext.audioFocus == AudioManager.AUDIOFOCUS_NONE + ) { + focusManager.handleStop() + } + this.context = audioContext.copy() + + // AudioManager values are set globally + audioManager.mode = context.audioMode + audioManager.isSpeakerphoneOn = context.isSpeakerphoneOn + + player?.let { p -> + p.stop() + prepared = false + // Context is only applied, once the player.reset() was called + p.updateContext(context) + source?.let { + p.setSource(it) + p.configAndPrepare() + } + } + } + + // Getters + + /** + * Returns the duration of the media in milliseconds, if available. + */ + fun getDuration(): Int? { + return if (prepared) player?.getDuration() else null + } + + /** + * Returns the current position of the playback in milliseconds, if available. + */ + fun getCurrentPosition(): Int? { + return if (prepared) player?.getCurrentPosition() else null + } + + val applicationContext: Context + get() = ref.getApplicationContext() + + val audioManager: AudioManager + get() = ref.getAudioManager() + + /** + * Playback handling methods + */ + fun play() { + if (!playing && !released) { + playing = true + if (player == null) { + initPlayer() + } else if (prepared) { + requestFocusAndStart() + } + } + } + + // Try to get audio focus and then start. + private fun requestFocusAndStart() { + focusManager.maybeRequestAudioFocus() + } + + fun stop() { + focusManager.handleStop() + if (released) { + return + } + if (releaseMode != ReleaseMode.RELEASE) { + pause() + if (prepared) { + if (player?.isLiveStream() == true) { + player?.stop() + prepared = false + player?.prepare() + } else { + // MediaPlayer does not allow to call player.seekTo after calling player.stop + seek(0) + } + } + } else { + release() + } + } + + fun release() { + focusManager.handleStop() + if (released) { + return + } + if (playing) { + player?.stop() + } + + // Setting source to null will reset released, prepared and playing + // and also calls player.release() + source = null + player = null + } + + fun pause() { + if (playing) { + playing = false + if (prepared) { + player?.pause() + } + } + } + + // seek operations cannot be called until after + // the player is ready. + fun seek(position: Int) { + shouldSeekTo = if (prepared && player?.isLiveStream() != true) { + player?.seekTo(position) + -1 + } else { + position + } + } + + /** + * Player callbacks + */ + fun onPrepared() { + prepared = true + ref.handleDuration(this) + if (playing) { + requestFocusAndStart() + } + if (shouldSeekTo >= 0 && player?.isLiveStream() != true) { + player?.seekTo(shouldSeekTo) + } + } + + fun onCompletion() { + if (releaseMode != ReleaseMode.LOOP) { + stop() + } + ref.handleComplete(this) + } + + @Suppress("UNUSED_PARAMETER") + fun onBuffering(percent: Int) { + // TODO(luan): expose this as a stream + } + + fun onSeekComplete() { + ref.handleSeekComplete(this) + } + + fun handleLog(message: String) { + ref.handleLog(this, message) + } + + fun handleError(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + ref.handleError(this, errorCode, errorMessage, errorDetails) + } + + fun onError(what: Int, extra: Int): Boolean { + val whatMsg = if (what == MediaPlayer.MEDIA_ERROR_SERVER_DIED) { + "MEDIA_ERROR_SERVER_DIED" + } else { + "MEDIA_ERROR_UNKNOWN {what:$what}" + } + val extraMsg = when (extra) { + MEDIA_ERROR_SYSTEM -> "MEDIA_ERROR_SYSTEM" + MediaPlayer.MEDIA_ERROR_IO -> "MEDIA_ERROR_IO" + MediaPlayer.MEDIA_ERROR_MALFORMED -> "MEDIA_ERROR_MALFORMED" + MediaPlayer.MEDIA_ERROR_UNSUPPORTED -> "MEDIA_ERROR_UNSUPPORTED" + MediaPlayer.MEDIA_ERROR_TIMED_OUT -> "MEDIA_ERROR_TIMED_OUT" + else -> "MEDIA_ERROR_UNKNOWN {extra:$extra}" + } + if (!prepared && extraMsg == "MEDIA_ERROR_SYSTEM") { + handleError( + "AndroidAudioError", + "Failed to set source. For troubleshooting, see: " + + "https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md", + "$whatMsg, $extraMsg", + ) + } else { + // When an error occurs, reset player to not [prepared]. + // Then no functions will be called, which end up in an illegal player state. + prepared = false + handleError("AndroidAudioError", whatMsg, extraMsg) + } + return false + } + + /** + * Internal logic. Private methods + */ + + /** + * Create new player + */ + private fun createPlayer(): PlayerWrapper { + return when (playerMode) { + MEDIA_PLAYER -> MediaPlayerWrapper(this) + LOW_LATENCY -> SoundPoolPlayer(this, soundPoolManager) + } + } + + /** + * Create new player, assign and configure source + */ + private fun initPlayer() { + val player = createPlayer() + // Need to set player before calling prepare, as onPrepared may is called before player is assigned + this.player = player + source?.let { + player.setSource(it) + player.configAndPrepare() + } + } + + private fun PlayerWrapper.configAndPrepare() { + setVolumeAndBalance(volume, balance) + setLooping(isLooping) + prepare() + } + + private fun PlayerWrapper.setVolumeAndBalance(volume: Float, balance: Float) { + val leftVolume = min(1f, 1f - balance) * volume + val rightVolume = min(1f, 1f + balance) * volume + setVolume(leftVolume, rightVolume) + } + + fun dispose() { + release() + eventHandler.dispose() + } +} diff --git a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/ByteDataSource.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/ByteDataSource.kt similarity index 50% rename from packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/ByteDataSource.kt rename to packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/ByteDataSource.kt index cb03c6e14..203dc0c06 100644 --- a/packages/audioplayers/android/src/main/kotlin/xyz/luan/audioplayers/ByteDataSource.kt +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/ByteDataSource.kt @@ -1,14 +1,15 @@ -package xyz.luan.audioplayers +package xyz.luan.audioplayers.source import android.media.MediaDataSource +import android.os.Build +import androidx.annotation.RequiresApi +@RequiresApi(Build.VERSION_CODES.M) class ByteDataSource( - private val data: ByteArray + private val data: ByteArray, ) : MediaDataSource() { @Synchronized - override fun getSize(): Long { - return data.size.toLong() - } + override fun getSize(): Long = data.size.toLong() @Synchronized override fun close() = Unit @@ -19,12 +20,16 @@ class ByteDataSource( return -1 } - var remainingSize = size - if (position + remainingSize > data.size) { - remainingSize -= position.toInt() + remainingSize - data.size - } + val remainingSize = computeRemainingSize(size, position) System.arraycopy(data, position.toInt(), buffer, offset, remainingSize) return remainingSize } + private fun computeRemainingSize(size: Int, position: Long): Int { + var remainingSize = size.toLong() + if (position + remainingSize > data.size) { + remainingSize -= position + remainingSize - data.size + } + return remainingSize.toInt() + } } diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/BytesSource.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/BytesSource.kt new file mode 100644 index 000000000..78c94f1d0 --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/BytesSource.kt @@ -0,0 +1,21 @@ +package xyz.luan.audioplayers.source + +import android.media.MediaPlayer +import android.os.Build +import androidx.annotation.RequiresApi +import xyz.luan.audioplayers.player.SoundPoolPlayer + +@RequiresApi(Build.VERSION_CODES.M) +data class BytesSource( + val dataSource: ByteDataSource, +) : Source { + constructor(bytes: ByteArray) : this(ByteDataSource(bytes)) + + override fun setForMediaPlayer(mediaPlayer: MediaPlayer) { + mediaPlayer.setDataSource(dataSource) + } + + override fun setForSoundPool(soundPoolPlayer: SoundPoolPlayer) { + error("Bytes sources are not supported on LOW_LATENCY mode yet.") + } +} diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/Source.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/Source.kt new file mode 100644 index 000000000..8d410bed7 --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/Source.kt @@ -0,0 +1,10 @@ +package xyz.luan.audioplayers.source + +import android.media.MediaPlayer +import xyz.luan.audioplayers.player.SoundPoolPlayer + +// TODO(luan) replace this indirection with a sealed interface once we have that option! +interface Source { + fun setForMediaPlayer(mediaPlayer: MediaPlayer) + fun setForSoundPool(soundPoolPlayer: SoundPoolPlayer) +} diff --git a/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt new file mode 100644 index 000000000..ad371e872 --- /dev/null +++ b/packages/audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt @@ -0,0 +1,53 @@ +package xyz.luan.audioplayers.source + +import android.media.MediaPlayer +import xyz.luan.audioplayers.player.SoundPoolPlayer +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.net.URI +import java.net.URL + +data class UrlSource( + val url: String, + val isLocal: Boolean, +) : Source { + override fun setForMediaPlayer(mediaPlayer: MediaPlayer) { + mediaPlayer.setDataSource(url) + } + + override fun setForSoundPool(soundPoolPlayer: SoundPoolPlayer) { + soundPoolPlayer.release() + soundPoolPlayer.urlSource = this + } + + fun getAudioPathForSoundPool(): String { + if (isLocal) { + return url.removePrefix("file://") + } + + return loadTempFileFromNetwork().absolutePath + } + + private fun loadTempFileFromNetwork(): File { + val bytes = downloadUrl(URI.create(url).toURL()) + val tempFile = File.createTempFile("sound", "") + FileOutputStream(tempFile).use { + it.write(bytes) + tempFile.deleteOnExit() + } + return tempFile + } + + private fun downloadUrl(url: URL): ByteArray { + val outputStream = ByteArrayOutputStream() + url.openStream().use { stream -> + val chunk = ByteArray(4096) + while (true) { + val bytesRead = stream.read(chunk).takeIf { it > 0 } ?: break + outputStream.write(chunk, 0, bytesRead) + } + } + return outputStream.toByteArray() + } +} diff --git a/packages/audioplayers_android/pubspec.yaml b/packages/audioplayers_android/pubspec.yaml new file mode 100644 index 000000000..fd5ebddb0 --- /dev/null +++ b/packages/audioplayers_android/pubspec.yaml @@ -0,0 +1,28 @@ +name: audioplayers_android +resolution: workspace +description: Android implementation of audioplayers, a Flutter plugin to play multiple audio files simultaneously +version: 5.2.1 +homepage: https://github.com/bluefireteam/audioplayers +repository: https://github.com/bluefireteam/audioplayers/tree/master/packages/audioplayers_android + +flutter: + plugin: + implements: audioplayers + platforms: + android: + package: xyz.luan.audioplayers + pluginClass: AudioplayersPlugin + +dependencies: + audioplayers_platform_interface: ^7.1.1 + flutter: + sdk: flutter + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + +environment: + sdk: ^3.6.0 + flutter: '>=3.27.0' diff --git a/packages/audioplayers_android_exo/.gitignore b/packages/audioplayers_android_exo/.gitignore new file mode 120000 index 000000000..cd2d10262 --- /dev/null +++ b/packages/audioplayers_android_exo/.gitignore @@ -0,0 +1 @@ +../audioplayers_android/.gitignore \ No newline at end of file diff --git a/packages/audioplayers_android_exo/.metadata b/packages/audioplayers_android_exo/.metadata new file mode 100644 index 000000000..434f1e5eb --- /dev/null +++ b/packages/audioplayers_android_exo/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: a0860f6e87ba4f9031bee4d6f56c08b970606bee + channel: dev + +project_type: plugin diff --git a/packages/audioplayers_android_exo/CHANGELOG.md b/packages/audioplayers_android_exo/CHANGELOG.md new file mode 100644 index 000000000..43cfd467a --- /dev/null +++ b/packages/audioplayers_android_exo/CHANGELOG.md @@ -0,0 +1,17 @@ +## 0.1.2+1 + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +## 0.1.2 + + - **FIX**: Make FocusManager compatible with Android API <= 25 (closes [#1895](https://github.com/bluefireteam/audioplayers/issues/1895)) ([#1904](https://github.com/bluefireteam/audioplayers/issues/1904)). ([41238d48](https://github.com/bluefireteam/audioplayers/commit/41238d4837fb5c59b8aaf2e7e8087268a160ebe7)) + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +## 0.1.1 + + - **FEAT**(android): ExoPlayer for Android ([#1691](https://github.com/bluefireteam/audioplayers/issues/1691)). ([a91c5b18](https://github.com/bluefireteam/audioplayers/commit/a91c5b185054986a2390d41593b5ee502ef96bdd)) + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +## 0.1.0 + +- **FEAT**: AudioPlayers for Android based on ExoPlayer diff --git a/packages/audioplayers_android_exo/LICENSE b/packages/audioplayers_android_exo/LICENSE new file mode 100644 index 000000000..5e5163f83 --- /dev/null +++ b/packages/audioplayers_android_exo/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audioplayers_android_exo/README.md b/packages/audioplayers_android_exo/README.md new file mode 100644 index 000000000..be65e52d7 --- /dev/null +++ b/packages/audioplayers_android_exo/README.md @@ -0,0 +1,22 @@ +

+ + AudioPlayers + +

+ +--- + +# audioplayers_android_exo +

+ + + + +

+ +The Android implementation of [`audioplayers`](https://pub.dev/packages/audioplayers). + +## Usage + +This package is [not endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can replace the default Android implementation of `audioplayers` with this package by adding `audioplayers_android_exo` to the apps `pubspec.yaml`. diff --git a/packages/audioplayers_android_exo/analysis_options.yaml b/packages/audioplayers_android_exo/analysis_options.yaml new file mode 100644 index 000000000..85732fa02 --- /dev/null +++ b/packages/audioplayers_android_exo/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/audioplayers_android_exo/android/.gitignore b/packages/audioplayers_android_exo/android/.gitignore new file mode 120000 index 000000000..a01a082a6 --- /dev/null +++ b/packages/audioplayers_android_exo/android/.gitignore @@ -0,0 +1 @@ +../../audioplayers_android/android/.gitignore \ No newline at end of file diff --git a/packages/audioplayers_android_exo/android/build.gradle b/packages/audioplayers_android_exo/android/build.gradle new file mode 100644 index 000000000..19f142101 --- /dev/null +++ b/packages/audioplayers_android_exo/android/build.gradle @@ -0,0 +1,87 @@ +group 'xyz.luan.audioplayers' +version '1.0-SNAPSHOT' + +buildscript { + ext.kotlin_version = '1.7.10' + ext.coroutines_version = '1.6.4' + + repositories { + google() + mavenCentral() + } + + dependencies { + classpath 'com.android.tools.build:gradle:7.3.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "de.mannodermaus.gradle.plugins:android-junit5:1.7.1.1" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply plugin: 'de.mannodermaus.android-junit5' + +android { + compileSdk 35 + + // Conditional for compatibility with AGP <4.2. + if (project.android.hasProperty('namespace')) { + namespace 'xyz.luan.audioplayers' + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + kotlinOptions { + jvmTarget = '1.8' + } + + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + minSdkVersion 23 + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + lint { + disable 'InvalidPackage' + } +} + +allprojects { + gradle.projectsEvaluated { + tasks.withType(JavaCompile) { + options.compilerArgs << "-Xlint:unchecked" << "-Xlint:deprecation" + } + } +} + +dependencies { + def exoplayer_version = "1.9.0" + implementation "androidx.media3:media3-exoplayer:${exoplayer_version}" + implementation "androidx.media3:media3-exoplayer-hls:${exoplayer_version}" + implementation "androidx.media3:media3-exoplayer-dash:${exoplayer_version}" + implementation "androidx.media3:media3-exoplayer-rtsp:${exoplayer_version}" + implementation "androidx.media3:media3-exoplayer-smoothstreaming:${exoplayer_version}" + + implementation "androidx.core:core-ktx:1.9.0" // Do not pump unless dropping support for AGP7 + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version" + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$coroutines_version" + testImplementation 'org.junit.jupiter:junit-jupiter:5.9.0' + testImplementation 'org.assertj:assertj-core:3.23.1' +} +repositories { + mavenCentral() +} diff --git a/packages/audioplayers_android_exo/android/gradle.properties b/packages/audioplayers_android_exo/android/gradle.properties new file mode 120000 index 000000000..a8da7d998 --- /dev/null +++ b/packages/audioplayers_android_exo/android/gradle.properties @@ -0,0 +1 @@ +../../audioplayers_android/android/gradle.properties \ No newline at end of file diff --git a/packages/audioplayers_android_exo/android/settings.gradle b/packages/audioplayers_android_exo/android/settings.gradle new file mode 100644 index 000000000..fef9ebff1 --- /dev/null +++ b/packages/audioplayers_android_exo/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'audioplayers_android_exo' diff --git a/packages/audioplayers_android_exo/android/src/main/AndroidManifest.xml b/packages/audioplayers_android_exo/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..0f77d9bf9 --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/AudioContextAndroid.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/AudioContextAndroid.kt new file mode 120000 index 000000000..270339c05 --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/AudioContextAndroid.kt @@ -0,0 +1 @@ +../../../../../../../../audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/AudioContextAndroid.kt \ No newline at end of file diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt new file mode 100644 index 000000000..a34fdaa04 --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/AudioplayersPlugin.kt @@ -0,0 +1,308 @@ +package xyz.luan.audioplayers + +import android.content.Context +import android.media.AudioManager +import android.os.Build +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.FlutterPlugin.FlutterPluginBinding +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import xyz.luan.audioplayers.player.WrappedPlayer +import xyz.luan.audioplayers.source.BytesSource +import xyz.luan.audioplayers.source.UrlSource +import java.io.FileNotFoundException +import java.util.concurrent.ConcurrentHashMap + +typealias FlutterHandler = (call: MethodCall, response: MethodChannel.Result) -> Unit + +class AudioplayersPlugin : FlutterPlugin { + private lateinit var methods: MethodChannel + private lateinit var globalMethods: MethodChannel + private lateinit var globalEvents: EventHandler + private lateinit var context: Context + private lateinit var binaryMessenger: BinaryMessenger + + private val players = ConcurrentHashMap() + private var defaultAudioContext = AudioContextAndroid() + + override fun onAttachedToEngine(binding: FlutterPluginBinding) { + context = binding.applicationContext + binaryMessenger = binding.binaryMessenger + methods = MethodChannel(binding.binaryMessenger, "xyz.luan/audioplayers") + methods.setMethodCallHandler { call, response -> safeCall(call, response, ::methodHandler) } + globalMethods = MethodChannel(binding.binaryMessenger, "xyz.luan/audioplayers.global") + globalMethods.setMethodCallHandler { call, response -> safeCall(call, response, ::globalMethodHandler) } + globalEvents = EventHandler(EventChannel(binding.binaryMessenger, "xyz.luan/audioplayers.global/events")) + } + + override fun onDetachedFromEngine(binding: FlutterPluginBinding) { + players.values.forEach { it.dispose() } + players.clear() + globalEvents.dispose() + } + + private fun safeCall( + call: MethodCall, + response: MethodChannel.Result, + handler: FlutterHandler, + ) { + try { + handler(call, response) + } catch (e: Throwable) { + response.error("Unexpected AndroidAudioError", e.message, e) + } + } + + private fun globalMethodHandler(call: MethodCall, response: MethodChannel.Result) { + when (call.method) { + "init" -> { + players.values.forEach { it.dispose() } + players.clear() + } + + "setAudioContext" -> { + val audioManager = getAudioManager() + audioManager.mode = defaultAudioContext.audioMode + audioManager.isSpeakerphoneOn = defaultAudioContext.isSpeakerphoneOn + + defaultAudioContext = call.audioContext() + } + + "emitLog" -> { + val message = call.argument("message") ?: error("message is required") + handleGlobalLog(message) + } + + "emitError" -> { + val code = call.argument("code") ?: error("code is required") + val message = call.argument("message") ?: error("message is required") + handleGlobalError(code, message, null) + } + + else -> { + response.notImplemented() + return + } + } + + response.success(1) + } + + private fun methodHandler(call: MethodCall, response: MethodChannel.Result) { + val playerId = call.argument("playerId") ?: return + if (call.method == "create") { + val eventHandler = EventHandler(EventChannel(binaryMessenger, "xyz.luan/audioplayers/events/$playerId")) + players[playerId] = WrappedPlayer(this, eventHandler, defaultAudioContext.copy()) + response.success(1) + return + } + val player = getPlayer(playerId) + try { + when (call.method) { + "setSourceUrl" -> { + val url = call.argument("url") ?: error("url is required") + val isLocal = call.argument("isLocal") ?: false + try { + player.source = UrlSource(url, isLocal) + } catch (e: FileNotFoundException) { + response.error( + "AndroidAudioError", + "Failed to set source. For troubleshooting, see: " + + "https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md", + e, + ) + return + } + } + + "setSourceBytes" -> { + val bytes = call.argument("bytes") ?: error("bytes are required") + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { + error("Operation not supported on Android <= M") + } + player.source = BytesSource(bytes) + } + + "resume" -> player.resume() + "pause" -> player.pause() + "stop" -> player.stop() + "release" -> player.release() + "seek" -> { + val position = call.argument("position") ?: error("position is required") + player.seek(position) + } + + "setVolume" -> { + val volume = call.argument("volume") ?: error("volume is required") + player.volume = volume.toFloat() + } + + "setBalance" -> { + val balance = call.argument("balance") ?: error("balance is required") + player.balance = balance.toFloat() + } + + "setPlaybackRate" -> { + val rate = call.argument("playbackRate") ?: error("playbackRate is required") + player.rate = rate.toFloat() + } + + "getDuration" -> { + response.success(player.getDuration()) + return + } + + "getCurrentPosition" -> { + response.success(player.getCurrentPosition()) + return + } + + "setReleaseMode" -> { + val releaseMode = call.enumArgument("releaseMode") ?: error("releaseMode is required") + player.releaseMode = releaseMode + } + + "setPlayerMode" -> { + val playerMode = call.enumArgument("playerMode") ?: error("playerMode is required") + if (playerMode == PlayerMode.LOW_LATENCY) { + player.handleError( + "AndroidAudioError", + "PlayerMode LowLatency is not supported for Exoplayer", + null, + ) + } + } + + "setAudioContext" -> { + val audioContext = call.audioContext() + player.updateAudioContext(audioContext) + } + + "emitLog" -> { + val message = call.argument("message") ?: error("message is required") + player.handleLog(message) + } + + "emitError" -> { + val code = call.argument("code") ?: error("code is required") + val message = call.argument("message") ?: error("message is required") + player.handleError(code, message, null) + } + + "dispose" -> { + player.dispose() + players.remove(playerId) + } + + else -> { + response.notImplemented() + return + } + } + response.success(1) + } catch (e: Exception) { + response.error("AndroidAudioError", e.message, e) + } + } + + private fun getPlayer(playerId: String): WrappedPlayer { + return players[playerId] ?: error("Player has not yet been created or has already been disposed.") + } + + fun getApplicationContext(): Context { + return context.applicationContext + } + + fun getAudioManager(): AudioManager { + return context.applicationContext.getSystemService(Context.AUDIO_SERVICE) as AudioManager + } + + fun handleDuration(player: WrappedPlayer) { + player.eventHandler.success( + "audio.onDuration", + hashMapOf("value" to (player.getDuration() ?: 0)), + ) + } + + fun handleComplete(player: WrappedPlayer) { + player.eventHandler.success("audio.onComplete") + } + + fun handlePrepared(player: WrappedPlayer, isPrepared: Boolean) { + player.eventHandler.success("audio.onPrepared", hashMapOf("value" to isPrepared)) + } + + fun handleLog(player: WrappedPlayer, message: String) { + player.eventHandler.success("audio.onLog", hashMapOf("value" to message)) + } + + fun handleGlobalLog(message: String) { + globalEvents.success("audio.onLog", hashMapOf("value" to message)) + } + + fun handleError(player: WrappedPlayer, errorCode: String?, errorMessage: String?, errorDetails: Any?) { + player.eventHandler.error(errorCode, errorMessage, errorDetails) + } + + fun handleGlobalError(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + globalEvents.error(errorCode, errorMessage, errorDetails) + } + + fun handleSeekComplete(player: WrappedPlayer) { + player.eventHandler.success("audio.onSeekComplete") + } +} + +private inline fun > MethodCall.enumArgument(name: String): T? { + val enumName = argument(name) ?: return null + return enumValueOf(enumName.split('.').last().toConstantCase()) +} + +fun String.toConstantCase(): String { + return replace(Regex("(.)(\\p{Upper})"), "$1_$2").replace(Regex("(.) (.)"), "$1_$2").uppercase() +} + +private fun MethodCall.audioContext(): AudioContextAndroid { + return AudioContextAndroid( + isSpeakerphoneOn = argument("isSpeakerphoneOn") ?: error("isSpeakerphoneOn is required"), + stayAwake = argument("stayAwake") ?: error("stayAwake is required"), + contentType = argument("contentType") ?: error("contentType is required"), + usageType = argument("usageType") ?: error("usageType is required"), + audioFocus = argument("audioFocus") ?: error("audioFocus is required"), + audioMode = argument("audioMode") ?: error("audioMode is required"), + ) +} + +class EventHandler(private val eventChannel: EventChannel) : EventChannel.StreamHandler { + private var eventSink: EventChannel.EventSink? = null + + init { + eventChannel.setStreamHandler(this) + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + eventSink = events + } + + override fun onCancel(arguments: Any?) { + eventSink = null + } + + fun success(method: String, arguments: Map = HashMap()) { + eventSink?.success(arguments + Pair("event", method)) + } + + fun error(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + eventSink?.error(errorCode, errorMessage, errorDetails) + } + + fun dispose() { + eventSink?.let { + it.endOfStream() + onCancel(null) + } + eventChannel.setStreamHandler(null) + } +} diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/PlayerMode.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/PlayerMode.kt new file mode 120000 index 000000000..49b0c99a4 --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/PlayerMode.kt @@ -0,0 +1 @@ +../../../../../../../../audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/PlayerMode.kt \ No newline at end of file diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt new file mode 120000 index 000000000..675ce1f0a --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt @@ -0,0 +1 @@ +../../../../../../../../audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/ReleaseMode.kt \ No newline at end of file diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/ExoPlayerWrapper.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/ExoPlayerWrapper.kt new file mode 100644 index 000000000..cca92c6b2 --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/ExoPlayerWrapper.kt @@ -0,0 +1,253 @@ +package xyz.luan.audioplayers.player + +import android.content.Context +import android.net.Uri +import android.os.Build +import android.util.SparseArray +import androidx.annotation.RequiresApi +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.C.TIME_UNSET +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.audio.AudioProcessor +import androidx.media3.common.audio.AudioProcessor.UnhandledAudioFormatException +import androidx.media3.common.audio.BaseAudioProcessor +import androidx.media3.common.audio.ChannelMixingMatrix +import androidx.media3.datasource.ByteArrayDataSource +import androidx.media3.datasource.DataSource +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.source.MediaSource +import androidx.media3.exoplayer.source.ProgressiveMediaSource +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.source.BytesSource +import xyz.luan.audioplayers.source.Source +import xyz.luan.audioplayers.source.UrlSource +import java.nio.ByteBuffer + +class ExoPlayerWrapper( + private val wrappedPlayer: WrappedPlayer, + appContext: Context, +) : PlayerWrapper { + + class ExoPlayerListener(private val wrappedPlayer: WrappedPlayer) : androidx.media3.common.Player.Listener { + override fun onPlayerError(error: PlaybackException) { + if (error.errorCode == PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED || + error.errorCode == PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND + ) { + wrappedPlayer.handleError( + errorCode = "AndroidAudioError", + errorMessage = "Failed to set source. For troubleshooting, see: " + + "https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md", + errorDetails = "${error.errorCodeName}\n${error.message}\n${error.stackTraceToString()}", + ) + return + } + wrappedPlayer.handleError( + errorCode = error.errorCodeName, + errorMessage = error.message, + errorDetails = error.stackTraceToString(), + ) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + when (playbackState) { + Player.STATE_IDLE -> {} // TODO(gustl22): may can use or leave as no-op + Player.STATE_BUFFERING -> wrappedPlayer.onBuffering(0) + Player.STATE_READY -> wrappedPlayer.onPrepared() + Player.STATE_ENDED -> wrappedPlayer.onCompletion() + } + } + } + + private var player: ExoPlayer + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private var channelMixingAudioProcessor = AdaptiveChannelMixingAudioProcessor() + private lateinit var audioSink: AudioSink + + init { + player = createPlayer(appContext) + } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + private fun createPlayer(appContext: Context): ExoPlayer { + val renderersFactory = object : DefaultRenderersFactory(appContext) { + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioTrackPlaybackParams: Boolean, + ): AudioSink { + audioSink = + DefaultAudioSink.Builder(appContext).setAudioProcessors(arrayOf(channelMixingAudioProcessor)) + .build() + return audioSink + } + } + + return ExoPlayer.Builder(appContext).setRenderersFactory(renderersFactory).build().apply { + addListener(ExoPlayerListener(wrappedPlayer)) + } + } + + override fun getDuration(): Int? { + if (player.isCurrentMediaItemLive) { + return null + } + return (player.duration.takeUnless { it == TIME_UNSET })?.toInt() + } + + override fun getCurrentPosition(): Int { + return player.currentPosition.toInt() + } + + override fun start() { + player.play() + } + + override fun pause() { + player.pause() + } + + override fun stop() { + player.pause() + player.seekTo(0) + } + + override fun seekTo(position: Int) { + player.seekTo(position.toLong()) + wrappedPlayer.onSeekComplete() + } + + override fun release() { + player.stop() + player.clearMediaItems() + } + + override fun dispose() { + release() + player.release() + } + + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + override fun setVolume(leftVolume: Float, rightVolume: Float) { + this.channelMixingAudioProcessor.putChannelMixingMatrix( + ChannelMixingMatrix(2, 2, floatArrayOf(leftVolume, 0f, 0f, rightVolume)), + ) + } + + override fun setRate(rate: Float) { + player.setPlaybackSpeed(rate) + } + + override fun setLooping(looping: Boolean) { + player.repeatMode = if (looping) { + Player.REPEAT_MODE_ONE + } else { + Player.REPEAT_MODE_OFF + } + } + + override fun updateContext(context: AudioContextAndroid) { + val builder = AudioAttributes.Builder() + builder.setContentType(context.contentType) + builder.setUsage(context.usageType) + + player.setAudioAttributes( + builder.build(), + false, + ) + } + + @RequiresApi(Build.VERSION_CODES.M) + @androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) + override fun setSource(source: Source) { + player.clearMediaItems() + if (source is UrlSource) { + player.setMediaItem(MediaItem.fromUri(source.url)) + } else if (source is BytesSource) { + val byteArrayDataSource = ByteArrayDataSource(source.data) + val factory = DataSource.Factory { byteArrayDataSource; } + val mediaSource: MediaSource = ProgressiveMediaSource.Factory(factory).createMediaSource( + MediaItem.fromUri(Uri.EMPTY), + ) + player.setMediaSource(mediaSource) + } + } + + override fun prepare() { + player.prepare() + } +} + +/** + * See Implementation of [androidx.media3.common.audio.ChannelMixingAudioProcessor] for reference. + * See: https://github.com/androidx/media/blob/8ea49025aaf14c7e7d953df8ca2f08a76d9d4275/libraries/common/src/main/java/androidx/media3/common/audio/ChannelMixingAudioProcessor.java + */ +@androidx.annotation.OptIn(androidx.media3.common.util.UnstableApi::class) +class AdaptiveChannelMixingAudioProcessor : BaseAudioProcessor() { + private val matrixByInputChannelCount: SparseArray = SparseArray() + + fun putChannelMixingMatrix(matrix: ChannelMixingMatrix) { + matrixByInputChannelCount.put(matrix.inputChannelCount, matrix) + } + + @Throws(UnhandledAudioFormatException::class) + override fun onConfigure(inputAudioFormat: AudioProcessor.AudioFormat): AudioProcessor.AudioFormat { + if (inputAudioFormat.encoding != C.ENCODING_PCM_16BIT) { + throw UnhandledAudioFormatException(inputAudioFormat) + } else { + // We keep the same format; we're not altering the channel count. + return inputAudioFormat + } + } + + override fun queueInput(inputBuffer: ByteBuffer) { + val channelMixingMatrix = matrixByInputChannelCount[inputAudioFormat.channelCount] + if (channelMixingMatrix == null || channelMixingMatrix.isIdentity) { + // No need to transform, if balance is equalized. + val outputBuffer = this.replaceOutputBuffer(inputBuffer.remaining()) + if (inputBuffer.hasRemaining()) { + outputBuffer.put(inputBuffer) + } + outputBuffer.flip() + return + } + + val outputBuffer = this.replaceOutputBuffer(inputBuffer.remaining()) + val inputChannelCount = channelMixingMatrix.inputChannelCount + val outputChannelCount = channelMixingMatrix.outputChannelCount + val outputFrame = FloatArray(outputChannelCount) + + while (inputBuffer.hasRemaining()) { + var inputValue: Short + var inputChannelIndex = 0 + while (inputChannelIndex < inputChannelCount) { + inputValue = inputBuffer.getShort() + + for (outputChannelIndex in 0 until outputChannelCount) { + outputFrame[outputChannelIndex] += channelMixingMatrix.getMixingCoefficient( + inputChannelIndex, + outputChannelIndex, + ) * inputValue.toFloat() + } + ++inputChannelIndex + } + + inputChannelIndex = 0 + while (inputChannelIndex < outputChannelCount) { + inputValue = + outputFrame[inputChannelIndex].toInt().coerceIn(-32768, 32767).toShort() + outputBuffer.put((inputValue.toInt() and 255).toByte()) + outputBuffer.put((inputValue.toInt() shr 8 and 255).toByte()) + outputFrame[inputChannelIndex] = 0.0f + ++inputChannelIndex + } + } + outputBuffer.flip() + } +} diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/FocusManager.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/FocusManager.kt new file mode 120000 index 000000000..e261e1bba --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/FocusManager.kt @@ -0,0 +1 @@ +../../../../../../../../../audioplayers_android/android/src/main/kotlin/xyz/luan/audioplayers/player/FocusManager.kt \ No newline at end of file diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/PlayerWrapper.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/PlayerWrapper.kt new file mode 100644 index 000000000..849350012 --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/PlayerWrapper.kt @@ -0,0 +1,24 @@ +package xyz.luan.audioplayers.player + +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.source.Source + +interface PlayerWrapper { + fun getDuration(): Int? + fun getCurrentPosition(): Int? + + fun start() + fun pause() + fun stop() + fun seekTo(position: Int) + + fun setVolume(leftVolume: Float, rightVolume: Float) + fun setRate(rate: Float) + fun setLooping(looping: Boolean) + fun updateContext(context: AudioContextAndroid) + fun setSource(source: Source) + + fun prepare() + fun release() + fun dispose() +} diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt new file mode 100644 index 000000000..d3f8f8ec0 --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/player/WrappedPlayer.kt @@ -0,0 +1,300 @@ +package xyz.luan.audioplayers.player + +import android.content.Context +import android.media.AudioManager +import xyz.luan.audioplayers.AudioContextAndroid +import xyz.luan.audioplayers.AudioplayersPlugin +import xyz.luan.audioplayers.EventHandler +import xyz.luan.audioplayers.ReleaseMode +import xyz.luan.audioplayers.source.Source +import kotlin.math.min + +class WrappedPlayer internal constructor( + private val ref: AudioplayersPlugin, + val eventHandler: EventHandler, + var context: AudioContextAndroid, +) { + private var player: PlayerWrapper? = null + + init { + createPlayer().also { + player = it + } + } + var source: Source? = null + set(value) { + if (field != value) { + field = value + prepared = false + if (value != null) { + released = false + player?.setSource(value) + player?.configAndPrepare() + } else { + released = true + playing = false + player?.release() + } + } else { + ref.handlePrepared(this, true) + } + } + + var volume = 1.0f + set(value) { + if (field != value) { + field = value + if (!released) { + player?.setVolumeAndBalance(value, balance) + } + } + } + + var balance = 0.0f + set(value) { + if (field != value) { + field = value + if (!released) { + player?.setVolumeAndBalance(volume, value) + } + } + } + + var rate = 1.0f + set(value) { + if (field != value) { + field = value + if (playing) { + player?.setRate(value) + } + } + } + + var releaseMode = ReleaseMode.RELEASE + set(value) { + if (field != value) { + field = value + if (!released) { + player?.setLooping(isLooping) + } + } + } + + val isLooping: Boolean + get() = releaseMode == ReleaseMode.LOOP + + var released = true + + var prepared: Boolean = false + set(value) { + if (field != value) { + field = value + ref.handlePrepared(this, value) + } + } + + var playing = false + var shouldSeekTo = -1 + + private val focusManager = FocusManager.create( + this, + onGranted = { + // Check if in playing state, as the focus can also be gained e.g. after a phone call, even if not playing. + if (playing) { + player?.start() + } + }, + onLoss = { isTransient -> + if (isTransient) { + // Do not check or set playing state, as the state should be recovered after granting focus again. + player?.pause() + } else { + // Audio focus won't be recovered + pause() + } + }, + ) + + fun updateAudioContext(audioContext: AudioContextAndroid) { + if (context == audioContext) { + return + } + if (context.audioFocus != AudioManager.AUDIOFOCUS_NONE && + audioContext.audioFocus == AudioManager.AUDIOFOCUS_NONE + ) { + focusManager.handleStop() + } + this.context = audioContext.copy() + + // AudioManager values are set globally + audioManager.mode = context.audioMode + audioManager.isSpeakerphoneOn = context.isSpeakerphoneOn + + player?.let { p -> + p.stop() + prepared = false + // Context is only applied, once the player.reset() was called + p.updateContext(context) + source?.let { + p.setSource(it) + p.configAndPrepare() + } + } + } + + // Getters + + /** + * Returns the duration of the media in milliseconds, if available. + */ + fun getDuration(): Int? { + return if (prepared) player?.getDuration() else null + } + + /** + * Returns the current position of the playback in milliseconds, if available. + */ + fun getCurrentPosition(): Int? { + return if (prepared) player?.getCurrentPosition() else null + } + + val applicationContext: Context + get() = ref.getApplicationContext() + + val audioManager: AudioManager + get() = ref.getAudioManager() + + /** + * Playback handling methods + */ + fun resume() { + if (!playing && !released) { + playing = true + if (prepared) { + requestFocusAndStart() + } + } + } + + // Try to get audio focus and then start. + private fun requestFocusAndStart() { + focusManager.maybeRequestAudioFocus() + } + + fun stop() { + focusManager.handleStop() + if (released) { + return + } + if (releaseMode != ReleaseMode.RELEASE) { + pause() + if (prepared) { + player?.stop() + } + } else { + release() + } + } + + fun release() { + focusManager.handleStop() + if (released) { + return + } + if (playing) { + player?.stop() + } + + // Setting source to null will reset released, prepared and playing + // and also calls player.release() + source = null + } + + fun pause() { + if (playing) { + playing = false + if (prepared) { + player?.pause() + } + } + } + + // seek operations cannot be called until after + // the player is ready. + fun seek(position: Int) { + shouldSeekTo = if (prepared) { + player?.seekTo(position) + -1 + } else { + position + } + } + + /** + * Player callbacks + */ + fun onPrepared() { + prepared = true + ref.handleDuration(this) + if (playing) { + requestFocusAndStart() + } + if (shouldSeekTo >= 0) { + player?.seekTo(shouldSeekTo) + } + } + + fun onCompletion() { + if (releaseMode != ReleaseMode.LOOP) { + stop() + } + ref.handleComplete(this) + } + + @Suppress("UNUSED_PARAMETER") + fun onBuffering(percent: Int) { + // TODO(luan): expose this as a stream + } + + fun onSeekComplete() { + ref.handleSeekComplete(this) + } + + fun handleLog(message: String) { + ref.handleLog(this, message) + } + + fun handleError(errorCode: String?, errorMessage: String?, errorDetails: Any?) { + ref.handleError(this, errorCode, errorMessage, errorDetails) + } + + /** + * Internal logic. Private methods + */ + + /** + * Create new player + */ + private fun createPlayer(): PlayerWrapper { + return ExoPlayerWrapper(this, ref.getApplicationContext()) + } + + private fun PlayerWrapper.configAndPrepare() { + setVolumeAndBalance(volume, balance) + setLooping(isLooping) + prepare() + } + + private fun PlayerWrapper.setVolumeAndBalance(volume: Float, balance: Float) { + val leftVolume = min(1f, 1f - balance) * volume + val rightVolume = min(1f, 1f + balance) * volume + setVolume(leftVolume, rightVolume) + } + + fun dispose() { + release() + player?.dispose() + player = null + eventHandler.dispose() + } +} diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/source/BytesSource.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/source/BytesSource.kt new file mode 100644 index 000000000..b82cf53f6 --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/source/BytesSource.kt @@ -0,0 +1,9 @@ +package xyz.luan.audioplayers.source + +import android.os.Build +import androidx.annotation.RequiresApi + +@RequiresApi(Build.VERSION_CODES.M) +data class BytesSource( + val data: ByteArray, +) : Source diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/source/Source.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/source/Source.kt new file mode 100644 index 000000000..9e916707b --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/source/Source.kt @@ -0,0 +1,3 @@ +package xyz.luan.audioplayers.source + +interface Source diff --git a/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt new file mode 100644 index 000000000..dbea62ed4 --- /dev/null +++ b/packages/audioplayers_android_exo/android/src/main/kotlin/xyz/luan/audioplayers/source/UrlSource.kt @@ -0,0 +1,6 @@ +package xyz.luan.audioplayers.source + +data class UrlSource( + val url: String, + val isLocal: Boolean, +) : Source diff --git a/packages/audioplayers_android_exo/pubspec.yaml b/packages/audioplayers_android_exo/pubspec.yaml new file mode 100644 index 000000000..d968732d9 --- /dev/null +++ b/packages/audioplayers_android_exo/pubspec.yaml @@ -0,0 +1,28 @@ +name: audioplayers_android_exo +resolution: workspace +description: Android implementation of audioplayers, a Flutter plugin to play multiple audio files simultaneously +version: 0.1.2+1 +homepage: https://github.com/bluefireteam/audioplayers +repository: https://github.com/bluefireteam/audioplayers/tree/master/packages/audioplayers_android_exo + +flutter: + plugin: + implements: audioplayers + platforms: + android: + package: xyz.luan.audioplayers + pluginClass: AudioplayersPlugin + +dependencies: + audioplayers_platform_interface: ^7.1.1 + flutter: + sdk: flutter + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + +environment: + sdk: ^3.6.0 + flutter: '>=3.27.0' diff --git a/packages/audioplayers_darwin/.gitignore b/packages/audioplayers_darwin/.gitignore new file mode 100644 index 000000000..9be145fde --- /dev/null +++ b/packages/audioplayers_darwin/.gitignore @@ -0,0 +1,29 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +# Libraries should not include pubspec.lock, per https://dart.dev/guides/libraries/private-files#pubspeclock. +/pubspec.lock +**/doc/api/ +.dart_tool/ +.packages +build/ diff --git a/packages/audioplayers_darwin/.metadata b/packages/audioplayers_darwin/.metadata new file mode 100644 index 000000000..8c15ad72b --- /dev/null +++ b/packages/audioplayers_darwin/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b + channel: stable + +project_type: plugin diff --git a/packages/audioplayers_darwin/CHANGELOG.md b/packages/audioplayers_darwin/CHANGELOG.md new file mode 100644 index 000000000..9a57f98e1 --- /dev/null +++ b/packages/audioplayers_darwin/CHANGELOG.md @@ -0,0 +1,415 @@ +## 6.3.0 + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + - **FEAT**: Support for Swift Package Manager ([#1908](https://github.com/bluefireteam/audioplayers/issues/1908)). ([e8f86e7b](https://github.com/bluefireteam/audioplayers/commit/e8f86e7bf80ddb8b0955d35c53f08cbf5f2d141b)) + +## 6.2.0 + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +## 6.1.1 + + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +## 6.1.0 + + - **FIX**: No-op on single player setAudioContext in desktop platforms ([#1888](https://github.com/bluefireteam/audioplayers/issues/1888)). ([50d7a8b8](https://github.com/bluefireteam/audioplayers/commit/50d7a8b89f47e3ef29e98cf2b74a582f78783d5e)) + - **FEAT**: ReleaseMode.release for ios, macos, windows, web, linux ([#1790](https://github.com/bluefireteam/audioplayers/issues/1790)). ([4ffc4029](https://github.com/bluefireteam/audioplayers/commit/4ffc4029d846d7c391c457b829c372c1763b7b50)) + +## 6.0.0 + +> Note: This release has breaking changes. + + - **FIX**(ios): 'audioProcessing' deprecated in iOS 10 ([#1756](https://github.com/bluefireteam/audioplayers/issues/1756)). ([81e5ea54](https://github.com/bluefireteam/audioplayers/commit/81e5ea542578f27c558f9a049996ecd8cb95c002)) + - **FEAT**: Support byte array and data URIs via mimeType ([#1763](https://github.com/bluefireteam/audioplayers/issues/1763)). ([eaf7ce86](https://github.com/bluefireteam/audioplayers/commit/eaf7ce86ad271097365fcf9e3a03fc341629ae47)) + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + +## 5.0.2 + + - **REFACTOR**: Lint Swift ([#1613](https://github.com/bluefireteam/audioplayers/issues/1613)). ([737aa94f](https://github.com/bluefireteam/audioplayers/commit/737aa94f7edb076d622c34e498b90f17c9959e9c)) + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Set playback rate only when playing ([#1658](https://github.com/bluefireteam/audioplayers/issues/1658)). ([d73c7d5c](https://github.com/bluefireteam/audioplayers/commit/d73c7d5c2ef13e8eff2c438b96ade6e2483a2014)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**(darwin): Start observing `AVPlayerItem.status` before being assigned to `AVPlayer` ([#1549](https://github.com/bluefireteam/audioplayers/issues/1549)). ([8c3a2138](https://github.com/bluefireteam/audioplayers/commit/8c3a213841c063d4a45bdb96e339ac338c7c8758)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + +## 5.0.1 + + - **REFACTOR**(darwin): Rearrange code ([#1585](https://github.com/bluefireteam/audioplayers/issues/1585)). ([13639d1f](https://github.com/bluefireteam/audioplayers/commit/13639d1f2fe5afbc17f4e862e2da0f7551b8fc3e)) + +## 5.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **FIX**: Default audio output to system preferences ([#1563](https://github.com/bluefireteam/audioplayers/issues/1563)). ([381c43e3](https://github.com/bluefireteam/audioplayers/commit/381c43e3725fbb0cb4fd35982893a3c92b188886)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +## 4.1.0 + + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + - **FEAT**: Adapt position update interval of darwin, linux, web ([#1492](https://github.com/bluefireteam/audioplayers/issues/1492)). ([ab5bdf6a](https://github.com/bluefireteam/audioplayers/commit/ab5bdf6a2bcbf7e984d4d897e43a67b3684c52d8)) + +## 4.0.1 + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +## 4.0.0 + +> Note: This release has breaking changes. + + - **FIX**(iOS): Default to speaker instead of earpiece on iOS ([#1408](https://github.com/bluefireteam/audioplayers/issues/1408)). ([4ea5907b](https://github.com/bluefireteam/audioplayers/commit/4ea5907bfe5ce83a0d1c100acfc0760d00c2b448)) + - **FEAT**(ios): set player context globally on `setAudioContext` for iOS only ([#1416](https://github.com/bluefireteam/audioplayers/issues/1416)). ([19af364b](https://github.com/bluefireteam/audioplayers/commit/19af364b7d0404ae436c54cdaa18d50f3a2aacd6)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + +## 3.0.1 + + - **FIX**: Remove one of the duplicated path_providers plugins + +## 3.0.0 + +> Note: This release does not have breaking changes, it was an accidental bump. + +## 2.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **FIX**: remove unused `defaultToSpeaker` in `AudioContextIOS` and replace with `AVAudioSessionOptions.defaultToSpeaker` ([#1374](https://github.com/bluefireteam/audioplayers/issues/1374)). ([d844ef9d](https://github.com/bluefireteam/audioplayers/commit/d844ef9def06fd5047076d9f4c371ad3be4c8dd5)) + +## 1.0.4 + + - **FIX**: infinity / nan on getDuration ([#1298](https://github.com/bluefireteam/audioplayers/issues/1298)). ([a4474dcf](https://github.com/bluefireteam/audioplayers/commit/a4474dcf5e14fbd74db8b4f19223b9bfa40ed5f5)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + +## 1.0.3 + + - **FIX**: infinity / nan on getDuration ([#1298](https://github.com/bluefireteam/audioplayers/issues/1298)). ([a4474dcf](https://github.com/bluefireteam/audioplayers/commit/a4474dcf5e14fbd74db8b4f19223b9bfa40ed5f5)) + +## 1.0.2 + + - **FIX**: update platform to 9.0 in podspec. (#1171). ([f8cbd972](https://github.com/bluefireteam/audioplayers/commit/f8cbd972b56b75c8cf204af38f953f322dc98ab1)) + - **FIX**: ios/macos no longer start audio when calling only setSourceUrl (#1206). ([c0e97f04](https://github.com/bluefireteam/audioplayers/commit/c0e97f04fb05fb109830d6363f5c44dccbd327b4)) + +## 1.0.1 + + - **FIX**: Make sure onComplete resets the position even when not looping (#1175). ([6e6005ac](https://github.com/bluefireteam/audioplayers/commit/6e6005ac98765aeeea62208b58a6cc6d0cb4b084)) + +## 1.0.0 + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +## 1.0.0-rc.4 + + - **FIX**: Fix iOS code that was missing from previous push (melos vs pub get issue) (#1122). ([fe737849](https://github.com/bluefireteam/audioplayers/commit/fe737849811d0de02cac56b73a613e4ceb78c218)) + +## 1.0.0-rc.3 + + - **FIX**: Volume and rate can be set before audio playing on iOS (#1113). ([eca1dd0e](https://github.com/bluefireteam/audioplayers/commit/eca1dd0e85abd72dc6c17bd2b7a24912664b98a5)) + +## 1.0.0-rc.2 + +## 1.0.0-rc.1 + + - First release after federation + +# Changelog + +## 0.20.2 +- Fix bug with inversed log levels + +## 0.20.1 +- Fix enum parsing on release mode on android + +## 0.20.0 +- Fix android/kotlin build for old projects +- Add method to clearNotification +- Add currentPosition stream on web +- Add seek on web +- Add a proper Logger +- Make setPlaybackRate signature consistent +- Fix fatal exception on Android API < 21 in WrappedMediaPlayer.kt setAttributes +- Add clearNotification method + +## 0.19.1 +- Add missing awaits for AudioCache +- Fix Kotlin Core version to v1.6.0 +- Fix iOS warning +- Fix README link to audio_cache.md to work on pub +- Fix documentation referencing old class +- Add web support for audioPlayer.getCurrentPosition +- Add web support for audioPlayer.getDuration +- Add web support for audioPlayer.setPlaybackRate +- Fix local file playback in LOW_LATENCY mode on Android + +## 0.19.0 +- Refactor Notifications code (small breaking changes) +- AudioCache for web +- Fixing basic features for Android lower than API 23 +- Fixing error after playing music several times with AudioCache +- Re-organize folder and file structure on the Dart side (project layout) +- Re-organize folders into a mono-repo +- Fix several bugs + +## 0.18.3 +- Fix Float vs Double mixup on Swift that prevent non-integer values for volume/playback +- Fix open sink issue / resource leak + +## 0.18.2 +- Changing Android minSdk verison to 16 +- Improve build processes and other small bug fixes + +## 0.18.1 +- Fix kotlin config issue for some apps +- Fix warning from pub +- Fix iOS lock screen +- Fix setUrl method + +## 0.18.0 +- Stable null-safety release +- Removed all the `@deprecated` code blocks + +## 0.17.4 +- Fix java.lang.UnsupportedOperationException on read-only kotlin map + +## 0.17.3 +- Backport some code to old kt (for now) + +## 0.17.2 +- Fix macos compilation issue +- Fix android for non-kotlin projects + +## 0.17.1 +- Use better algorithm for speed modulation on iOS +- Extracted and refactored all the notifications code onto the new file +- Add more checks and make sure notifcations code is not ran when it shouldn't +- Add more useful info to the troubleshoot guide + +## 0.17.0 +- Swift conversion of the darwin code + +## 0.16.2 +- Overhauled our contributing guidelines +- Improve docs around player state +- Update dependencies versions + +## 0.16.1 +- Fix Exception thrown when calling audioPlayer.dispose +- Fix bug with AudioCache crash on iOS + +## 0.16.0 +- Implemented stream routing for iOS +- Call release on dispose +- Fix iOS build +- Breaking change audio cache prefix in order to allow override 'assets' + +## audioplayers 0.15.1 +- Fix web for release mode + +## audioplayers 0.15.0 +- Improve loop/readme for web support +- Audio cache support for web +- Re-adding partial web support + +## audioplayers 0.14.3 +- Add next and previous command for ios + +## audioplayers 0.14.2 +- Fix pubspec problem because of web file + +## audioplayers 0.14.1 +- Adding linter, tests and flutter_driver integration tests to a CI (github actions) +- Minor fixes to the APIs and documentation +- Fix restarting the playback of a failed AVPlayerItem +- Prevent exceptions when null values are passed to notifications center +- Prevent crash by checking if headlessServiceInitialized before invoking onNotificationBackgroundPlayerStateChanged + +## audioplayers 0.14.0 +- Adding macOs support +- ios:fix lack of seek completion handle +- ios Delay start fixed + +## audioplayers 0.13.7 +- Bump dependencies, improve gitignore +- Upgrade pubspec pattern + +## audioplayers 0.13.6 +- added `setPlaybackRate` feature for Android +- Automatic detect address is local or remote (thanks, @saeed-golshan) + +## audioplayers 0.13.5 +- fixed crash on iOS when `startHeadlessService()` wasn't called on `AudioPlayer` (by @JesseScott) + +## audioplayers 0.13.4 +- fixing missing cleanup on hot restart on Android +- Background notification updates on iOS + +## audioplayers 0.13.3 +- audio notification area fixes +- fix when other apps are playing sounds +- fix android race condition +- Support for registering plugin in background enviroment +- fix typos and docs + +## audioplayers 0.13.2 +- Handling plugin dealloc and onTimeInterval crashs (thanks @chedechao111) +- Audio position update when the audio is paused (thanks @bjornjacobs) + +## audioplayers 0.13.1 +- Added stayAwake feature (thanks, @danielR2001) +- Improved dispose method (thanks, @hugocbpassos) +- Added getCurrentPosition (thanks, @hariom08) +- Some bug fixes and small changes + +## audioplayers 0.13.0 +- Call onDurationChanged after setUrl() to be consistent with ios version (thanks @subhash279) +- Adding getDuration feature iOS/Android (thanks @alecorsino) + +## audioplayers 0.12.1 +- Fixes bug where the stream handlers were not called due to exception on the handler +- Proper error message when errors in the dart handler occurs + +## audioplayers 0.12.0 +- Update to path_provider 1.1.0 +- Upgrade to Swift 5 in example project setting (thanks @jerryzhoujw) + +## audioplayers 0.11.0 +- **Breaking change**. Migrate from the deprecated original Android Support + Library to AndroidX. This shouldn't result in any functional changes, but it + requires any Android apps using this plugin to [also + migrate](https://developer.android.com/jetpack/androidx/migrate) if they're + using the original support library. + +## audioplayers 0.10.1 +- Seek and play now works with milliseconds instead of second (thanks, @catoldcui and @erickzanardo) + +## audioplayers 0.10.0 +- Added a low latency api for android (thanks, @feroult) + +## audioplayers 0.9.0 +- Improved callbacks using Streams to allow for multiple subscibers (thanks, @LucasCLuk) +- Update uuid version to 2.0.0 (thanks, @BeMacized) + +## audioplayers 0.8.2 +- Update path_provider version (thanks, @apiraino) + +## audioplayers 0.8.1 +- Fix for duration when playing a stream +- Added respectSilence flag in audioplayers, or isNotification for play methos in audio_cache + False by default, to use player for local notification. Silent when device is in silent mode. + +## audioplayers 0.8.0 +- Allow setting seek position in play function (thanks @rob-patchett) +- Get duration from the underlaying asset instead of from AVPlayerItem (thanks @andressade) +- Adding player state (thanks @renancaraujo) +- Set the audio session to active (thanks @benwicks) +- Delay seek operations on Android until player is ready (thanks @jeffmikels) + +## audioplayers 0.7.8 +- Fix bug regarding name clash with other plugins (thanks @imtaehyun) + +## audioplayers 0.7.7 +- Fix bug when using nested files with audio cache (thanks @hotstu for reporting and @eclewlow for fixing) + +## audioplayers 0.7.6 +- Fix the nefarious bug of 'sound only playing through headphones' (thanks so much, @tsun424) + +## audioplayers 0.7.5 +- Fix SDK constraint for Dart 2.1 (thanks @snoofer and @sroddy) + +## audioplayers 0.7.4 +- Some more fixes to work without errors with Dart 2 stronger types + +## audioplayers 0.7.3 +- Support Android SDK 16-20 (thanks, @sroddy) +- Avoid restarting a looping player if is stopped (thanks, @sroddy) + +## audioplayers 0.7.2 +- Bug fixes for iOS + +## audioplayers 0.7.1 +- Formatting + +## audioplayers 0.7.0 + +- Improved lifecycle handling for android +- Big performance boots +- Allows for finer control of releasing (with setReleaseMode, setUrl, resume, release) +- Allows for setting the volume at any time (with setVolume) +- Added LOOP as a ReleaseMode options, making it significantly faster +- Some other refactorings + +## audioplayers 0.6.0 + +- Major Refactoring! +- Renaming everything to audioplayers (mind the s) +- Better logging +- Added AudioCache (imported from Flame) +- Adding tests! +- Adding better example +- Greatly improving README +- Lots of other minor tweaks + +## audioplayers 0.5.2 + +- don't call the onClomplete hook when you manually stop the audio + +## audioplayers 0.5.1 + +- fix for dart 2 (thanks to @efortuna) + +## audioplayers 0.5.0 + +- improves Android performance by not calling `prepare` on the main thread + +## audioplayers 0.4.1 + +- fix `seek` for iOS + +## audioplayers 0.4.0 + +- volume controls + +## audioplayers 0.3.0 + +- working on iOS (thanks @feroult <3) + +## audioplayers 0.2.0 + +- adding disable log option + +## audioplayers 0.1.0 + +- support for multiple audios simultaneously + +## 0.2.0 + +- support for local files + +## 0.1.0 + +- update to the current Plugin API +- move to https://github.com/rxlabz/audioplayer + +## 0.0.2 + +Separated handlers for position, duration, completion and errors + +- setDurationHandler(TimeChangeHandler handler) +- setPositionHandler(TimeChangeHandler handler) +- setCompletionHandler(VoidCallback callback) +- setErrorHandler(ErrorHandler handler) + +- new typedef +```dart +typedef void TimeChangeHandler(Duration duration); +typedef void ErrorHandler(String message); +``` + +## 0.0.1 + +- first POC : + - methods : play, pause, stop + - a globalHandler for position, duration, completion and errors diff --git a/packages/audioplayers_darwin/LICENSE b/packages/audioplayers_darwin/LICENSE new file mode 100644 index 000000000..1a581b05c --- /dev/null +++ b/packages/audioplayers_darwin/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audioplayers_darwin/README.md b/packages/audioplayers_darwin/README.md new file mode 100644 index 000000000..06dd63f45 --- /dev/null +++ b/packages/audioplayers_darwin/README.md @@ -0,0 +1,23 @@ +

+ + AudioPlayers + +

+ +--- + +# audioplayers_darwin +

+ + + + +

+ +The iOS and macOS (Darwin) implementation of [`audioplayers`](https://pub.dev/packages/audioplayers). + +## Usage + +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can simply use `audioplayers` normally. +This package will be automatically included in your app when you do, so you do not need to add it to your `pubspec.yaml`. diff --git a/packages/audioplayers_darwin/analysis_options.yaml b/packages/audioplayers_darwin/analysis_options.yaml new file mode 100644 index 000000000..85732fa02 --- /dev/null +++ b/packages/audioplayers_darwin/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/audioplayers/ios/.gitignore b/packages/audioplayers_darwin/darwin/.gitignore similarity index 91% rename from packages/audioplayers/ios/.gitignore rename to packages/audioplayers_darwin/darwin/.gitignore index aa479fd3c..61f69c47a 100644 --- a/packages/audioplayers/ios/.gitignore +++ b/packages/audioplayers_darwin/darwin/.gitignore @@ -2,6 +2,8 @@ .vagrant/ .sconsign.dblite .svn/ +.build/ +.swiftpm/ .DS_Store *.swp @@ -34,4 +36,5 @@ Icon? .tags* /Flutter/Generated.xcconfig +/Flutter/ephemeral/ /Flutter/flutter_export_environment.sh \ No newline at end of file diff --git a/packages/audioplayers_darwin/darwin/audioplayers_darwin.podspec b/packages/audioplayers_darwin/darwin/audioplayers_darwin.podspec new file mode 100644 index 000000000..4e0d9c4fc --- /dev/null +++ b/packages/audioplayers_darwin/darwin/audioplayers_darwin.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint audioplayers.podspec' to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'audioplayers_darwin' + s.version = '0.0.1' + s.summary = 'Flutter Audioplayers Plugin' + s.description = 'Darwin implementation of audioplayers, a Flutter plugin to play multiple audio files simultaneously.' + s.homepage = 'https://github.com/bluefireteam/audioplayers' + s.license = { :type => 'MIT', :file => '../LICENSE' } + s.author = { 'Blue Fire' => 'contact@blue-fire.xyz' } + s.source = { :path => '.' } + s.documentation_url = 'https://pub.dev/packages/audioplayers' + s.source_files = 'audioplayers_darwin/Sources/audioplayers_darwin/**/*.swift' + s.ios.dependency 'Flutter' + s.osx.dependency 'FlutterMacOS' + s.ios.deployment_target = '13.0' + s.osx.deployment_target = '10.15' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } +end diff --git a/packages/audioplayers_darwin/darwin/audioplayers_darwin/Package.swift b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Package.swift new file mode 100644 index 000000000..8253be13a --- /dev/null +++ b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Package.swift @@ -0,0 +1,34 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "audioplayers_darwin", + platforms: [ + .iOS("13.0"), + .macOS("10.15"), + ], + products: [ + .library(name: "audioplayers-darwin", targets: ["audioplayers_darwin"]) + ], + dependencies: [], + targets: [ + .target( + name: "audioplayers_darwin", + dependencies: [], + resources: [ + // TODO: If your plugin requires a privacy manifest + // (e.g. if it uses any required reason APIs), update the PrivacyInfo.xcprivacy file + // to describe your plugin's privacy impact, and then uncomment this line. + // For more information, see: + // https://developer.apple.com/documentation/bundleresources/privacy_manifest_files + // .process("PrivacyInfo.xcprivacy"), + + // TODO: If you have other resources that need to be bundled with your plugin, refer to + // the following instructions to add them: + // https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package + ] + ) + ] +) diff --git a/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioContext.swift b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioContext.swift new file mode 100644 index 000000000..0f3a582da --- /dev/null +++ b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioContext.swift @@ -0,0 +1,133 @@ +import MediaPlayer + +#if os(iOS) + struct AudioContext { + let category: AVAudioSession.Category + let options: [AVAudioSession.CategoryOptions] + + init() { + self.category = .playback + self.options = [] + } + + init( + category: AVAudioSession.Category, + options: [AVAudioSession.CategoryOptions] + ) { + self.category = category + self.options = options + } + + public func activateAudioSession( + active: Bool + ) throws { + let session = AVAudioSession.sharedInstance() + try session.setActive(active) + } + + public func apply() throws { + let session = AVAudioSession.sharedInstance() + let combinedOptions = options.reduce(AVAudioSession.CategoryOptions()) { + [$0, $1] + } + try session.setCategory(category, options: combinedOptions) + } + + public static func parse(args: [String: Any]) throws -> AudioContext? { + guard let categoryString = args["category"] as! String? else { + throw AudioPlayerError.error("Null value received for category") + } + guard let category = try parseCategory(category: categoryString) else { + return nil + } + + guard let optionStrings = args["options"] as! [String]? else { + throw AudioPlayerError.error("Null value received for options") + } + let options = try optionStrings.compactMap { + try parseCategoryOption(option: $0) + } + if optionStrings.count != options.count { + return nil + } + + return AudioContext( + category: category, + options: options + ) + } + + private static func parseCategory(category: String) throws -> AVAudioSession.Category? { + switch category { + case "ambient": + return .ambient + case "soloAmbient": + return .soloAmbient + case "playback": + return .playback + case "record": + return .record + case "playAndRecord": + return .playAndRecord + case "multiRoute": + return .multiRoute + default: + throw AudioPlayerError.error("Invalid Category \(category)") + } + } + + private static func parseCategoryOption(option: String) throws -> AVAudioSession + .CategoryOptions? + { + switch option { + case "mixWithOthers": + return .mixWithOthers + case "duckOthers": + return .duckOthers + case "allowBluetooth": + return .allowBluetooth + case "defaultToSpeaker": + return .defaultToSpeaker + case "interruptSpokenAudioAndMixWithOthers": + return .interruptSpokenAudioAndMixWithOthers + case "allowBluetoothA2DP": + if #available(iOS 10.0, *) { + return .allowBluetoothA2DP + } else { + throw AudioPlayerError.warning( + "Category Option allowBluetoothA2DP is only available on iOS 10+") + } + case "allowAirPlay": + if #available(iOS 10.0, *) { + return .allowAirPlay + } else { + throw AudioPlayerError.warning( + "Category Option allowAirPlay is only available on iOS 10+") + } + case "overrideMutedMicrophoneInterruption": + if #available(iOS 14.5, *) { + return .overrideMutedMicrophoneInterruption + } else { + throw AudioPlayerError.warning( + "Category Option overrideMutedMicrophoneInterruption is only available on iOS 14.5+") + } + default: + throw AudioPlayerError.error("Invalid Category Option \(option)") + } + } + } +#else + // no-op impl of AudioContext for macos + struct AudioContext { + func activateAudioSession(active: Bool) throws { + } + + func apply() throws { + throw AudioPlayerError.warning("AudioContext configuration is not available on macOS") + } + + static func parse(args: [String: Any]) throws -> AudioContext? { + return AudioContext() + } + } +#endif diff --git a/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioPlayerError.swift b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioPlayerError.swift new file mode 100644 index 000000000..4abaf6bc9 --- /dev/null +++ b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioPlayerError.swift @@ -0,0 +1,4 @@ +enum AudioPlayerError: Error { + case error(String) + case warning(String) +} diff --git a/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioplayersDarwinPlugin.swift b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioplayersDarwinPlugin.swift new file mode 100644 index 000000000..1696f5c98 --- /dev/null +++ b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/AudioplayersDarwinPlugin.swift @@ -0,0 +1,517 @@ +import AVFoundation +import AVKit + +#if os(iOS) + import Flutter + import UIKit + import MediaPlayer +#else + import FlutterMacOS + import AVFAudio +#endif + +let channelName = "xyz.luan/audioplayers" + +let globalChannelName = "xyz.luan/audioplayers.global" + +public class AudioplayersDarwinPlugin: NSObject, FlutterPlugin { + var registrar: FlutterPluginRegistrar + var binaryMessenger: FlutterBinaryMessenger + var methods: FlutterMethodChannel + var globalMethods: FlutterMethodChannel + var globalEvents: GlobalAudioPlayersStreamHandler + + var globalContext = AudioContext() + var players = [String: WrappedMediaPlayer]() + + init( + registrar: FlutterPluginRegistrar, + binaryMessenger: FlutterBinaryMessenger, + methodChannel: FlutterMethodChannel, + globalMethodChannel: FlutterMethodChannel, + globalEventChannel: FlutterEventChannel + ) { + self.registrar = registrar + self.binaryMessenger = binaryMessenger + self.methods = methodChannel + self.globalMethods = globalMethodChannel + self.globalEvents = GlobalAudioPlayersStreamHandler(channel: globalEventChannel) + + do { + try globalContext.apply() + } catch { + // ignore error on initialization + } + + super.init() + + self.globalMethods.setMethodCallHandler(handleGlobalMethodCall) + } + + public static func register(with registrar: FlutterPluginRegistrar) { + // apparently there is a bug in Flutter causing some inconsistency between Flutter and FlutterMacOS + // See: https://github.com/flutter/flutter/issues/118103 + #if os(iOS) + let binaryMessenger = registrar.messenger() + #else + let binaryMessenger = registrar.messenger + #endif + + let methods = FlutterMethodChannel(name: channelName, binaryMessenger: binaryMessenger) + let globalMethods = FlutterMethodChannel( + name: globalChannelName, binaryMessenger: binaryMessenger) + let globalEvents = FlutterEventChannel( + name: globalChannelName + "/events", binaryMessenger: binaryMessenger) + + let instance = AudioplayersDarwinPlugin( + registrar: registrar, + binaryMessenger: binaryMessenger, + methodChannel: methods, + globalMethodChannel: globalMethods, + globalEventChannel: globalEvents) + registrar.addMethodCallDelegate(instance, channel: methods) + } + + public func detachFromEngine(for registrar: FlutterPluginRegistrar) { + Task { @MainActor [weak self] in + guard let self = self else { + return + } + await disposePlayers() + self.globalMethods.setMethodCallHandler(nil) + self.globalEvents.dispose() + } + } + + private func disposePlayers() async { + for (_, player) in self.players { + await player.dispose() + } + self.players = [:] + } + + private func handleGlobalMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) { + Task { @MainActor in + await handleAsyncGlobalMethodCall(call: call, result: result) + } + } + + @MainActor + private func handleAsyncGlobalMethodCall(call: FlutterMethodCall, result: @escaping FlutterResult) + async + { + let method = call.method + + guard let args = call.arguments as? [String: Any] else { + result( + FlutterError( + code: "DarwinAudioError", message: "Failed to parse call.arguments from Flutter.", + details: nil)) + return + } + + // global handlers (no playerId) + if method == "init" { + await disposePlayers() + } else if method == "setAudioContext" { + #if os(iOS) + do { + guard let context = try AudioContext.parse(args: args) else { + result( + FlutterError( + code: "DarwinAudioError", + message: "Error calling setAudioContext, context could not be parsed", + details: nil)) + return + } + globalContext = context + + try globalContext.apply() + } catch let error { + result( + FlutterError( + code: "DarwinAudioError", message: "Error configuring global audio session: \(error)", + details: nil)) + } + #else + globalEvents.onLog(message: "Setting AudioContext is not supported on this platform") + #endif + } else if method == "emitLog" { + guard let message = args["message"] as? String else { + result( + FlutterError( + code: "DarwinAudioError", message: "Error calling emitLog, message cannot be null", + details: nil)) + return + } + globalEvents.onLog(message: message) + } else if method == "emitError" { + guard let code = args["code"] as? String else { + result( + FlutterError( + code: "DarwinAudioError", message: "Error calling emitError, code cannot be null", + details: nil)) + return + } + guard let message = args["message"] as? String else { + result( + FlutterError( + code: "DarwinAudioError", message: "Error calling emitError, message cannot be null", + details: nil)) + return + } + globalEvents.onError(code: code, message: message, details: nil) + } else { + result(FlutterMethodNotImplemented) + return + } + + // default result (bypass by adding `return` to your branch) + result(1) + } + + public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + Task { @MainActor in + await handleAsync(call, result: result) + } + } + + @MainActor + private func handleAsync(_ call: FlutterMethodCall, result: @escaping FlutterResult) async { + let method = call.method + + guard let args = call.arguments as? [String: Any] else { + result( + FlutterError( + code: "DarwinAudioError", message: "Failed to parse call.arguments from Flutter.", + details: nil)) + return + } + + // player specific handlers + guard let playerId = args["playerId"] as? String else { + result( + FlutterError( + code: "DarwinAudioError", message: "Call missing mandatory parameter playerId.", + details: nil)) + return + } + + if method == "create" { + self.createPlayer(playerId: playerId) + result(1) + return + } + + guard let player = self.getPlayer(playerId: playerId) else { + result( + FlutterError( + code: "DarwinAudioError", + message: "Player has not yet been created or has already been disposed.", details: nil)) + return + } + + if method == "pause" { + player.pause() + } else if method == "resume" { + player.resume() + } else if method == "stop" { + await player.stop() + } else if method == "release" { + await player.release() + } else if method == "seek" { + guard let position = args["position"] as? Int else { + result( + FlutterError( + code: "DarwinAudioError", message: "Null position received on seek", details: nil)) + return + } + let time = toCMTime(millis: position) + await player.seek(time: time) + } else if method == "setSourceUrl" { + let url: String? = args["url"] as? String + let mimeType: String? = args["mimeType"] as? String + let isLocal: Bool = (args["isLocal"] as? Bool) ?? false + + if url == nil { + result( + FlutterError( + code: "DarwinAudioError", message: "Null URL received on setSourceUrl", details: nil)) + return + } + + do { + try await player.setSourceUrl( + url: url!, isLocal: isLocal, + mimeType: mimeType + ) + } catch let error { + player.eventHandler.onError( + code: "DarwinAudioError", + message: "Failed to set source. For troubleshooting, see " + + "https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md", + details: "AVPlayerItem.Status.failed on setSourceUrl: \(error)") + } + + } else if method == "setSourceBytes" { + result( + FlutterError( + code: "DarwinAudioError", message: "setSourceBytes is not currently implemented on iOS", + details: nil)) + return + } else if method == "getDuration" { + let duration = player.getDuration() + result(duration) + } else if method == "setVolume" { + guard let volume = args["volume"] as? Double else { + result( + FlutterError( + code: "DarwinAudioError", message: "Error calling setVolume, volume cannot be null", + details: nil)) + return + } + + player.setVolume(volume: volume) + } else if method == "setBalance" { + player.eventHandler.onLog(message: "setBalance is not currently implemented on iOS") + result(0) + return + } else if method == "getCurrentPosition" { + let currentPosition = player.getCurrentPosition() + result(currentPosition) + return + } else if method == "setPlaybackRate" { + guard let playbackRate = args["playbackRate"] as? Double else { + result( + FlutterError( + code: "DarwinAudioError", + message: "Error calling setPlaybackRate, playbackRate cannot be null", details: nil)) + return + } + player.setPlaybackRate(playbackRate: playbackRate) + } else if method == "setReleaseMode" { + guard let releaseModeStr = args["releaseMode"] as? String else { + result( + FlutterError( + code: "DarwinAudioError", + message: "Error calling setReleaseMode, releaseMode cannot be null", details: nil)) + return + } + player.releaseMode = ReleaseMode(rawValue: String(releaseModeStr.split(separator: ".")[1]))! + } else if method == "setPlayerMode" { + // no-op for darwin; only one player mode + } else if method == "setAudioContext" { + #if os(iOS) + player.eventHandler.onLog( + message: + "iOS does not allow for player-specific audio contexts; `setAudioContext` will set the global audio context instead (like `global.setAudioContext`)." + ) + do { + guard let context = try AudioContext.parse(args: args) else { + result( + FlutterError( + code: "DarwinAudioError", + message: "Error calling setAudioContext, context could not be parsed", + details: nil)) + return + } + globalContext = context + + try globalContext.apply() + } catch let error { + result( + FlutterError( + code: "DarwinAudioError", message: "Error configuring audio session: \(error)", + details: nil)) + } + #else + player.eventHandler.onLog(message: "Setting AudioContext is not supported on this platform") + #endif + } else if method == "emitLog" { + guard let message = args["message"] as? String else { + result( + FlutterError( + code: "DarwinAudioError", message: "Error calling emitLog, message cannot be null", + details: nil)) + return + } + player.eventHandler.onLog(message: message) + } else if method == "emitError" { + guard let code = args["code"] as? String else { + result( + FlutterError( + code: "DarwinAudioError", message: "Error calling emitError, code cannot be null", + details: nil)) + return + } + guard let message = args["message"] as? String else { + result( + FlutterError( + code: "DarwinAudioError", message: "Error calling emitError, message cannot be null", + details: nil)) + return + } + player.eventHandler.onError(code: code, message: message, details: nil) + } else if method == "dispose" { + await player.dispose() + self.players[playerId] = nil + } else { + result(FlutterMethodNotImplemented) + return + } + + // default result (bypass by adding `return` to your branch) + result(1) + } + + @MainActor + func createPlayer(playerId: String) { + let eventChannel = FlutterEventChannel( + name: channelName + "/events/" + playerId, binaryMessenger: self.binaryMessenger) + + let eventHandler = AudioPlayersStreamHandler(channel: eventChannel) + + let newPlayer = WrappedMediaPlayer( + reference: self, + eventHandler: eventHandler + ) + players[playerId] = newPlayer + } + + func getPlayer(playerId: String) -> WrappedMediaPlayer? { + return players[playerId] + } + + @MainActor + func controlAudioSession() { + let anyIsPlaying = players.values.contains { player in + player.isPlaying + } + + do { + try globalContext.activateAudioSession(active: anyIsPlaying) + } catch let error { + self.globalEvents.onError( + code: "DarwinAudioError", message: "Error configuring audio session: \(error)", details: nil + ) + } + } +} + +class AudioPlayersStreamHandler: NSObject, FlutterStreamHandler { + var eventChannel: FlutterEventChannel + var sink: FlutterEventSink? + // When calling dispose, we must emit a FlutterEndOfEventStream, then wait for onCancel to be called by Flutter, in order to release the stream handler. + // Otherwise an error is thrown, that the "cancel" method is not implemented. + private var isDisposed = false + + init(channel: FlutterEventChannel) { + self.eventChannel = channel + super.init() + eventChannel.setStreamHandler(self) + } + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + self.sink = events + // events(FlutterEndOfEventStream) // when stream is over + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.sink = nil + return nil + } + + private func sendEvent(_ event: Any?) { + if let eventSink = self.sink { + DispatchQueue.main.async { + eventSink(event) + } + } + } + + func onSeekComplete() { + sendEvent(["event": "audio.onSeekComplete"]) + } + + func onComplete() { + sendEvent(["event": "audio.onComplete"]) + } + + func onDuration(millis: Int) { + sendEvent(["event": "audio.onDuration", "value": millis] as [String: Any]) + } + + func onPrepared(isPrepared: Bool) { + sendEvent(["event": "audio.onPrepared", "value": isPrepared] as [String: Any]) + } + + func onLog(message: String) { + sendEvent(["event": "audio.onLog", "value": message]) + } + + func onError(code: String, message: String, details: Any?) { + sendEvent(FlutterError(code: code, message: message, details: details)) + } + + func dispose() { + onError( + code: "DarwinAudioError", + message: + "Stream was still listened to before disposing. Ensure to cancel all subscriptions before calling dispose.", + details: nil) + sendEvent(FlutterEndOfEventStream) + eventChannel.setStreamHandler(nil) + } +} + +class GlobalAudioPlayersStreamHandler: NSObject, FlutterStreamHandler { + var eventChannel: FlutterEventChannel + var sink: FlutterEventSink? + + init(channel: FlutterEventChannel) { + self.eventChannel = channel + super.init() + eventChannel.setStreamHandler(self) + } + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) + -> FlutterError? + { + self.sink = events + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.sink = nil + return nil + } + + private func sendEvent(_ event: Any?) { + if let eventSink = self.sink { + DispatchQueue.main.async { + eventSink(event) + } + } + } + + func onLog(message: String) { + sendEvent(["event": "audio.onLog", "value": message]) + } + + func onError(code: String, message: String, details: Any?) { + sendEvent(FlutterError(code: code, message: message, details: details)) + } + + func dispose() { + onError( + code: "DarwinAudioError", + message: + "Stream was still listened to before disposing. Ensure to cancel all subscriptions before calling dispose.", + details: nil) + sendEvent(FlutterEndOfEventStream) + eventChannel.setStreamHandler(nil) + } +} diff --git a/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/Utils.swift b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/Utils.swift new file mode 100644 index 000000000..0be74e1a3 --- /dev/null +++ b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/Utils.swift @@ -0,0 +1,44 @@ +import AVKit + +extension String { + func deletingPrefix(_ prefix: String) -> String { + guard self.hasPrefix(prefix) else { + return self + } + return String(self.dropFirst(prefix.count)) + } +} + +func toCMTime(millis: Int) -> CMTime { + return toCMTime(millis: Float(millis)) +} + +func toCMTime(millis: Double) -> CMTime { + return toCMTime(millis: Float(millis)) +} + +func toCMTime(millis: Float) -> CMTime { + return CMTimeMakeWithSeconds(Float64(millis) / 1000, preferredTimescale: Int32(NSEC_PER_SEC)) +} + +func fromCMTime(time: CMTime) -> Int { + guard CMTIME_IS_NUMERIC(time) else { + return 0 + } + let seconds: Float64 = CMTimeGetSeconds(time) + let milliseconds: Int = Int(seconds * 1000) + return milliseconds +} + +class TimeObserver { + let player: AVPlayer + let observer: Any + + init( + player: AVPlayer, + observer: Any + ) { + self.player = player + self.observer = observer + } +} diff --git a/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/WrappedMediaPlayer.swift b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/WrappedMediaPlayer.swift new file mode 100644 index 000000000..bcde73ca8 --- /dev/null +++ b/packages/audioplayers_darwin/darwin/audioplayers_darwin/Sources/audioplayers_darwin/WrappedMediaPlayer.swift @@ -0,0 +1,288 @@ +import AVKit + +private let defaultPlaybackRate: Double = 1.0 + +private let defaultVolume: Double = 1.0 + +private let defaultReleaseMode: ReleaseMode = ReleaseMode.release + +typealias Completer = () -> Void + +typealias CompleterError = (Error?) -> Void + +enum ReleaseMode: String { + case stop + case release + case loop +} + +@MainActor class WrappedMediaPlayer { + private(set) var eventHandler: AudioPlayersStreamHandler + private(set) var isPlaying: Bool + var releaseMode: ReleaseMode + + private var reference: AudioplayersDarwinPlugin + private var player: AVPlayer + private var playbackRate: Double + private var volume: Double + private var url: String? + + private var completionObserver: TimeObserver? + private var playerItemStatusObservation: NSKeyValueObservation? + + init( + reference: AudioplayersDarwinPlugin, + eventHandler: AudioPlayersStreamHandler, + player: AVPlayer = AVPlayer.init(), + playbackRate: Double = defaultPlaybackRate, + volume: Double = defaultVolume, + releaseMode: ReleaseMode = defaultReleaseMode, + url: String? = nil + ) { + self.reference = reference + self.eventHandler = eventHandler + self.player = player + self.completionObserver = nil + self.playerItemStatusObservation = nil + + self.isPlaying = false + self.playbackRate = playbackRate + self.volume = volume + self.releaseMode = releaseMode + self.url = url + } + + func setSourceUrl( + url: String, + isLocal: Bool, + mimeType: String? = nil + ) async throws { + let playbackStatus = player.currentItem?.status + + if self.url != url || playbackStatus == .failed || playbackStatus == nil { + reset() + self.url = url + let playerItem = try createPlayerItem(url: url, isLocal: isLocal, mimeType: mimeType) + // Need to observe item status immediately after creating: + try await setUpPlayerItemStatusObservation(playerItem) + // Needs to be called after the preparation has completed. + self.updateDuration() + + self.setUpSoundCompletedObserver(self.player, playerItem) + self.eventHandler.onPrepared(isPrepared: true) + } else { + if playbackStatus == .readyToPlay { + self.eventHandler.onPrepared(isPrepared: true) + } + } + } + + func getDuration() -> Int? { + guard let duration = getDurationCMTime() else { + return nil + } + return fromCMTime(time: duration) + } + + func getCurrentPosition() -> Int? { + guard let time = getCurrentCMTime() else { + return nil + } + return fromCMTime(time: time) + } + + func pause() { + isPlaying = false + player.pause() + } + + func resume() { + isPlaying = true + configParameters(player: player) + if #available(iOS 10.0, macOS 10.12, *) { + player.playImmediately(atRate: Float(playbackRate)) + } else { + player.play() + } + updateDuration() + } + + func setVolume(volume: Double) { + self.volume = volume + player.volume = Float(volume) + } + + func setPlaybackRate(playbackRate: Double) { + self.playbackRate = playbackRate + if isPlaying { + // Setting the rate causes the player to resume playing. So setting it only, when already playing. + player.rate = Float(playbackRate) + } + } + + func seek(time: CMTime) async { + guard let currentItem = player.currentItem else { + return + } + await currentItem.seek(to: time) + if !self.isPlaying { + self.player.pause() + } + self.eventHandler.onSeekComplete() + } + + func stop() async { + pause() + if releaseMode == ReleaseMode.release { + await release() + } else if (getCurrentPosition() ?? 0) != 0 { + await seek(time: toCMTime(millis: 0)) + } + } + + func release() async { + if self.isPlaying { + pause() + } + self.reset() + } + + func dispose() async { + await release() + self.eventHandler.dispose() + } + + private func getDurationCMTime() -> CMTime? { + return player.currentItem?.asset.duration + } + + private func getCurrentCMTime() -> CMTime? { + return player.currentItem?.currentTime() + } + + private func createPlayerItem( + url: String, + isLocal: Bool, + mimeType: String? = nil + ) throws -> AVPlayerItem { + guard + let parsedUrl = isLocal + ? URL(fileURLWithPath: url.deletingPrefix("file://")) : URL(string: url) + else { + throw AudioPlayerError.error("Url not valid: \(url)") + } + + let playerItem: AVPlayerItem + + if let unwrappedMimeType = mimeType { + if #available(iOS 17, macOS 14.0, *) { + let asset = AVURLAsset( + url: parsedUrl, options: [AVURLAssetOverrideMIMETypeKey: unwrappedMimeType]) + playerItem = AVPlayerItem(asset: asset) + } else { + let asset = AVURLAsset( + url: parsedUrl, options: ["AVURLAssetOutOfBandMIMETypeKey": unwrappedMimeType]) + playerItem = AVPlayerItem(asset: asset) + } + } else { + playerItem = AVPlayerItem(url: parsedUrl) + } + + playerItem.audioTimePitchAlgorithm = AVAudioTimePitchAlgorithm.timeDomain + return playerItem + } + + private func setUpPlayerItemStatusObservation( + _ playerItem: AVPlayerItem + ) async throws { + try await withCheckedThrowingContinuation { continuation in + playerItemStatusObservation = playerItem.observe(\AVPlayerItem.status) { + [weak self] (playerItem, change) in + guard let self = self else { + return + } + let status = playerItem.status + self.eventHandler.onLog(message: "player status: \(status), change: \(change)") + + switch status { + case .readyToPlay: + continuation.resume() + case .failed: + self.reset() + continuation.resume(throwing: AudioPlayerError.error("Failed to set playerItem")) + default: + // Do not resume continuation yet + break + } + } + // Replacing the player item triggers continuation of the observation. + self.player.replaceCurrentItem(with: playerItem) + } + + playerItemStatusObservation?.invalidate() + playerItemStatusObservation = nil + } + + private func setUpSoundCompletedObserver(_ player: AVPlayer, _ playerItem: AVPlayerItem) { + let observer = NotificationCenter.default.addObserver( + forName: NSNotification.Name.AVPlayerItemDidPlayToEndTime, + object: playerItem, + queue: nil + ) { + (notification) in + Task { @MainActor [weak self] in + guard let self = self else { + return + } + await self.onSoundComplete() + } + } + self.completionObserver = TimeObserver(player: player, observer: observer) + } + + private func configParameters(player: AVPlayer) { + if isPlaying { + player.volume = Float(volume) + player.rate = Float(playbackRate) + } + } + + private func reset() { + playerItemStatusObservation?.invalidate() + playerItemStatusObservation = nil + if let cObserver = completionObserver { + NotificationCenter.default.removeObserver(cObserver.observer) + completionObserver = nil + } + player.replaceCurrentItem(with: nil) + self.url = nil + } + + private func updateDuration() { + guard let duration = player.currentItem?.asset.duration else { + return + } + if CMTimeGetSeconds(duration) > 0 { + let millis = fromCMTime(time: duration) + eventHandler.onDuration(millis: millis) + } + } + + private func onSoundComplete() async { + if !isPlaying { + return + } + + reference.controlAudioSession() + eventHandler.onComplete() + + await seek(time: toCMTime(millis: 0)) + if self.releaseMode == ReleaseMode.loop { + self.resume() + } else if self.releaseMode == ReleaseMode.release { + await self.release() + } else { + self.isPlaying = false + } + } +} diff --git a/packages/audioplayers_darwin/pubspec.yaml b/packages/audioplayers_darwin/pubspec.yaml new file mode 100644 index 000000000..ec07aaf71 --- /dev/null +++ b/packages/audioplayers_darwin/pubspec.yaml @@ -0,0 +1,31 @@ +name: audioplayers_darwin +resolution: workspace +description: iOS and macOS implementation of audioplayers, a Flutter plugin to play multiple audio files simultaneously +version: 6.3.0 +homepage: https://github.com/bluefireteam/audioplayers +repository: https://github.com/bluefireteam/audioplayers/tree/master/packages/audioplayers_darwin + +flutter: + plugin: + implements: audioplayers + platforms: + ios: + pluginClass: AudioplayersDarwinPlugin + sharedDarwinSource: true + macos: + pluginClass: AudioplayersDarwinPlugin + sharedDarwinSource: true + +dependencies: + audioplayers_platform_interface: ^7.1.1 + flutter: + sdk: flutter + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + +environment: + sdk: ^3.6.0 + flutter: '>=3.27.0' diff --git a/packages/audioplayers_linux/.gitignore b/packages/audioplayers_linux/.gitignore new file mode 100644 index 000000000..04c2400af --- /dev/null +++ b/packages/audioplayers_linux/.gitignore @@ -0,0 +1,17 @@ +*.iml +.DS_Store +.atom/ +.idea +.packages +.dart_tool/ +.pub/ +build/ +ios/.generated/ +packages +.classpath +.project +.settings +.vscode +testing +.flutter-plugins-dependencies +flutter_export_environment.sh diff --git a/packages/audioplayers_linux/.metadata b/packages/audioplayers_linux/.metadata new file mode 100644 index 000000000..434f1e5eb --- /dev/null +++ b/packages/audioplayers_linux/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: a0860f6e87ba4f9031bee4d6f56c08b970606bee + channel: dev + +project_type: plugin diff --git a/packages/audioplayers_linux/CHANGELOG.md b/packages/audioplayers_linux/CHANGELOG.md new file mode 100644 index 000000000..acd027206 --- /dev/null +++ b/packages/audioplayers_linux/CHANGELOG.md @@ -0,0 +1,93 @@ +## 4.2.1 + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +## 4.2.0 + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +## 4.1.1 + + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +## 4.1.0 + + - **FIX**: No-op on single player setAudioContext in desktop platforms ([#1888](https://github.com/bluefireteam/audioplayers/issues/1888)). ([50d7a8b8](https://github.com/bluefireteam/audioplayers/commit/50d7a8b89f47e3ef29e98cf2b74a582f78783d5e)) + - **FEAT**: ReleaseMode.release for ios, macos, windows, web, linux ([#1790](https://github.com/bluefireteam/audioplayers/issues/1790)). ([4ffc4029](https://github.com/bluefireteam/audioplayers/commit/4ffc4029d846d7c391c457b829c372c1763b7b50)) + +## 4.0.1 + + - **DOCS**: Add Fedora/RHEL Dependency instructions ([#1851](https://github.com/bluefireteam/audioplayers/issues/1851)). ([b401a23c](https://github.com/bluefireteam/audioplayers/commit/b401a23c934c93a78893bb2def011cd10508c33b)) + +## 4.0.0 + +> Note: This release has breaking changes. + + - **FIX**(linux): Handle failures of OnMediaStateChange in OnMediaError ([#1731](https://github.com/bluefireteam/audioplayers/issues/1731)). ([3a5c6dca](https://github.com/bluefireteam/audioplayers/commit/3a5c6dca5dd9476765a976724e3ca89574794cb0)) + - **FIX**: Wait for seek to complete ([#1712](https://github.com/bluefireteam/audioplayers/issues/1712)). ([fd33b1d0](https://github.com/bluefireteam/audioplayers/commit/fd33b1d073280797cdd88fb6324cc1906bfd5957)) + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + - **BREAKING** **CHORE**: Upgrade to Flutter 3.13.0 ([#1612](https://github.com/bluefireteam/audioplayers/issues/1612)). ([1a3de1ac](https://github.com/bluefireteam/audioplayers/commit/1a3de1acd5a8b90b6d9c0d0f2a7141723c277c24)) + +## 3.1.0 + + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + - **FEAT**: Release source for Web, Linux, Windows ([#1517](https://github.com/bluefireteam/audioplayers/issues/1517)). ([09496dcb](https://github.com/bluefireteam/audioplayers/commit/09496dcbf478af330e37be833184439b43b5ac44)) + - **DOCS**: Manual Flutter installation on Linux setup ([#1631](https://github.com/bluefireteam/audioplayers/issues/1631)). ([9086e75a](https://github.com/bluefireteam/audioplayers/commit/9086e75a9503bdb84f372b5e09a4b225d3fae5f6)) + +## 3.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +## 2.1.0 + + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + - **FEAT**: Adapt position update interval of darwin, linux, web ([#1492](https://github.com/bluefireteam/audioplayers/issues/1492)). ([ab5bdf6a](https://github.com/bluefireteam/audioplayers/commit/ab5bdf6a2bcbf7e984d4d897e43a67b3684c52d8)) + +## 2.0.1 + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +## 2.0.0 + +> Note: This release has breaking changes. + + - **FEAT**(windows): show nuget download info explicitely in verbose mode ([#1449](https://github.com/bluefireteam/audioplayers/issues/1449)). ([136028fa](https://github.com/bluefireteam/audioplayers/commit/136028fa1cbcf38f80e9cc7ad78b3bb89d2c6d30)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **DOCS**: Fix LICENSE files for windows and linux ([#1431](https://github.com/bluefireteam/audioplayers/issues/1431)). ([1f84e857](https://github.com/bluefireteam/audioplayers/commit/1f84e857a112e663fff73c4e7c6875ebb72c783d)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + +## 1.0.4 + +> Note: This release was an accidental bump. + +## 1.0.3 + + - Update a dependency to the latest release. + +## 1.0.2 + + - **FIX**: play sound, when initialized ([#1332](https://github.com/bluefireteam/audioplayers/issues/1332)). ([2ed91fee](https://github.com/bluefireteam/audioplayers/commit/2ed91feec4d3528a4edff635331bd3aad938afd7)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + +## 1.0.1 + + - **FIX**: emit position event immediately when resume (#1222). ([94c73482](https://github.com/bluefireteam/audioplayers/commit/94c73482b0141d5f6c202219948fc79bac40b288)) + - **DOCS**: update README, Linux: replace with symlink, update Requirements (#1190). ([72e3d500](https://github.com/bluefireteam/audioplayers/commit/72e3d50067e274a8efb6b646a3318ae5fa097a77)) + +## 1.0.0 + + - **FIX**: missing onDuration event, free previous source when set url on Linux (#1129). ([b523a39e](https://github.com/bluefireteam/audioplayers/commit/b523a39e253dd461b07c360d7547eef9bb54cd65)) + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +## 1.0.0-rc.3 + + - **FEAT**: Linux platform support (closes #798) (#1110). ([74616c54](https://github.com/bluefireteam/audioplayers/commit/74616c5471fb942d8f08c41de50c93d4387f8916)) + diff --git a/packages/audioplayers_linux/LICENSE b/packages/audioplayers_linux/LICENSE new file mode 100644 index 000000000..1a581b05c --- /dev/null +++ b/packages/audioplayers_linux/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audioplayers_linux/README.md b/packages/audioplayers_linux/README.md new file mode 100644 index 000000000..24e402fd2 --- /dev/null +++ b/packages/audioplayers_linux/README.md @@ -0,0 +1,75 @@ +

+ + AudioPlayers + +

+ +--- + +# audioplayers_linux +

+ + + + +

+ +The Linux implementation of [`audioplayers`](https://pub.dev/packages/audioplayers). + +## Usage + +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can simply use `audioplayers` normally. +This package will be automatically included in your app when you do, so you do not need to add it to your `pubspec.yaml`. + +## Setup for Linux + +> Note: If Flutter was installed via [Snap](https://docs.flutter.dev/get-started/install/linux#install-flutter-using-snapd), you might encounter build errors due to dependency mismatching (like `glibc`). Check out how to [install the Flutter SDK manually](https://docs.flutter.dev/get-started/install/linux#install-flutter-manually) or build your application on a former Ubuntu release, e.g. `ubuntu:20.04` via `lxd`. + +### Debian + +#### Dev Dependencies + +[Flutter](https://docs.flutter.dev/get-started/install/linux#linux-setup) dependencies: + +```bash +sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev +``` + +[GStreamer](https://gstreamer.freedesktop.org/documentation/installing/on-linux.html?gi-language=c): + +```bash +sudo apt-get install libgstreamer1.0-dev libgstreamer-plugins-base1.0-dev +``` + +#### App Dependencies + +Optional GStreamer Plugins (e.g. for `.m3u8`): + +```bash +sudo apt-get install gstreamer1.0-plugins-good gstreamer1.0-plugins-bad +``` + +### ArchLinux + +For Arch, simply install gstreamer and its plugins via `pacman`: + +```bash +sudo pacman -S gstreamer gst-libav gst-plugins-base gst-plugins-good +``` + +You can install additional plugins as needed following [the Wiki](https://wiki.archlinux.org/title/GStreamer). + +### Fedora/RHEL + +[Flutter](https://docs.flutter.dev/get-started/install/linux#linux-setup) dependencies: + +```bash +sudo dnf install clang cmake ninja-build pkg-config +``` + +[GStreamer](https://gstreamer.freedesktop.org/documentation/installing/on-linux.html?gi-language=c): + +```bash +sudo dnf install gstreamer1-devel gstreamer1-plugins-base-devel +``` diff --git a/packages/audioplayers_linux/analysis_options.yaml b/packages/audioplayers_linux/analysis_options.yaml new file mode 100644 index 000000000..85732fa02 --- /dev/null +++ b/packages/audioplayers_linux/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/audioplayers_linux/linux/.gitignore b/packages/audioplayers_linux/linux/.gitignore new file mode 100644 index 000000000..d896f143f --- /dev/null +++ b/packages/audioplayers_linux/linux/.gitignore @@ -0,0 +1,20 @@ +flutter/ + +# CLion build files. +cmake-build-debug + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/audioplayers_linux/linux/CMakeLists.txt b/packages/audioplayers_linux/linux/CMakeLists.txt new file mode 100644 index 000000000..c46f7c216 --- /dev/null +++ b/packages/audioplayers_linux/linux/CMakeLists.txt @@ -0,0 +1,52 @@ +# See: https://github.com/flutter/flutter/blob/bc0ec85717de14677a0253814faead368ac2abe8/packages/flutter_tools/templates/app_shared/linux.tmpl/CMakeLists.txt.tmpl +cmake_minimum_required(VERSION 3.10) +set(PROJECT_NAME "audioplayers_linux") +project(${PROJECT_NAME} LANGUAGES CXX) +include(FetchContent) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "${PROJECT_NAME}_plugin") + +add_library(${PLUGIN_NAME} SHARED + "audioplayers_linux_plugin.cc" + "audio_player.h" + "audio_player.cc" +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES CXX_VISIBILITY_PRESET hidden) +target_compile_features(${PLUGIN_NAME} PRIVATE cxx_std_17) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_search_module(GLIB REQUIRED glib-2.0) +pkg_check_modules(GSTREAMER REQUIRED gstreamer-1.0) +pkg_check_modules(GST_APP REQUIRED gstreamer-app-1.0) +pkg_check_modules(GSTREAMER-AUDIO REQUIRED gstreamer-audio-1.0) +include_directories( + ${GLIB_INCLUDE_DIRS} + ${GSTREAMER_INCLUDE_DIRS} + ${GSTREAMER-APP_INCLUDE_DIRS} + ${GSTREAMER-AUDIO_INCLUDE_DIRS} + ${CMAKE_CURRENT_SOURCE_DIR} +) +link_directories( + ${GLIB_LIBRARY_DIRS} + ${GSTREAMER_LIBRARY_DIRS} + ${GSTREAMER-APP_LIBRARY_DIRS} + ${GSTREAMER-AUDIO_LIBRARY_DIRS} +) +target_include_directories(${PLUGIN_NAME} PRIVATE ${GST_INCLUDE_DIRS} ${GLIB_INCLUDE_DIRS}) +target_link_libraries(${PLUGIN_NAME} PRIVATE ${GST_APP_LIBRARIES} ${GLIB_LIBRARIES}) + +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter) +target_link_libraries(${PLUGIN_NAME} PRIVATE PkgConfig::GTK) + +# List of absolute paths to libraries that should be bundled with the plugin +set(audioplayers_linux_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/packages/audioplayers_linux/linux/audio_player.cc b/packages/audioplayers_linux/linux/audio_player.cc new file mode 100644 index 000000000..afe235168 --- /dev/null +++ b/packages/audioplayers_linux/linux/audio_player.cc @@ -0,0 +1,478 @@ +#include "audio_player.h" +#include +#define STR_LINK_TROUBLESHOOTING \ + "https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md" + +AudioPlayer::AudioPlayer(std::string playerId, + FlMethodChannel* methodChannel, + FlEventChannel* eventChannel) + : _playerId(playerId), _eventChannel(eventChannel) { + // GStreamer lib only needs to be initialized once, but doing it while + // registering the plugin can be problematic as it likely needs a GUI to be + // present. Calling it multiple times is fine. + gst_init(NULL, NULL); + + playbin = gst_element_factory_make("playbin", NULL); + if (!playbin) { + throw "Not all elements could be created."; + } + + // Setup stereo balance controller + panorama = gst_element_factory_make("audiopanorama", NULL); + if (panorama) { + audiobin = gst_bin_new(NULL); + audiosink = gst_element_factory_make("autoaudiosink", NULL); + + gst_bin_add_many(GST_BIN(audiobin), panorama, audiosink, NULL); + gst_element_link(panorama, audiosink); + + GstPad* sinkpad = gst_element_get_static_pad(panorama, "sink"); + panoramaSinkPad = gst_ghost_pad_new("sink", sinkpad); + gst_element_add_pad(audiobin, panoramaSinkPad); + gst_object_unref(GST_OBJECT(sinkpad)); + + g_object_set(G_OBJECT(playbin), "audio-sink", audiobin, NULL); + g_object_set(G_OBJECT(panorama), "method", 1, NULL); + } + + // Setup source options + g_signal_connect(playbin, "source-setup", + G_CALLBACK(AudioPlayer::SourceSetup), &source); + + bus = gst_element_get_bus(playbin); + + // Watch bus messages for one time events + gst_bus_add_watch(bus, (GstBusFunc)AudioPlayer::OnBusMessage, this); +} + +AudioPlayer::~AudioPlayer() {} + +void AudioPlayer::SourceSetup(GstElement* playbin, + GstElement* source, + GstElement** p_src) { + // Allow sources from unencrypted / misconfigured connections + if (g_object_class_find_property(G_OBJECT_GET_CLASS(source), "ssl-strict") != + 0) { + g_object_set(G_OBJECT(source), "ssl-strict", FALSE, NULL); + } +}; + +void AudioPlayer::SetSourceUrl(std::string url) { + if (_url != url) { + _url = url; + gst_element_set_state(playbin, GST_STATE_NULL); + _isInitialized = false; + _isPlaying = false; + if (!_url.empty()) { + g_object_set(GST_OBJECT(playbin), "uri", _url.c_str(), NULL); + if (playbin->current_state != GST_STATE_READY) { + GstStateChangeReturn ret = + gst_element_set_state(playbin, GST_STATE_READY); + if (ret == GST_STATE_CHANGE_FAILURE) { + throw "Unable to set the pipeline to GST_STATE_READY."; + } + } + } + } else { + this->OnPrepared(true); + } +} + +void AudioPlayer::ReleaseMediaSource() { + if (_isPlaying) + _isPlaying = false; + if (_isInitialized) + _isInitialized = false; + _url.clear(); + + GstState playbinState; + gst_element_get_state(playbin, &playbinState, NULL, GST_CLOCK_TIME_NONE); + if (playbinState > GST_STATE_NULL) { + gst_element_set_state(playbin, GST_STATE_NULL); + } +} + +gboolean AudioPlayer::OnBusMessage(GstBus* bus, + GstMessage* message, + AudioPlayer* data) { + switch (GST_MESSAGE_TYPE(message)) { + case GST_MESSAGE_ERROR: { + GError* err; + gchar* debug; + + gst_message_parse_error(message, &err, &debug); + data->OnMediaError(err, debug); + g_error_free(err); + g_free(debug); + break; + } + case GST_MESSAGE_STATE_CHANGED: + GstState old_state, new_state; + + gst_message_parse_state_changed(message, &old_state, &new_state, NULL); + data->OnMediaStateChange(GST_MESSAGE_SRC(message), &old_state, + &new_state); + break; + case GST_MESSAGE_EOS: + data->OnPlaybackEnded(); + break; + case GST_MESSAGE_DURATION_CHANGED: + data->OnDurationUpdate(); + break; + case GST_MESSAGE_ASYNC_DONE: + if (!data->_isSeekCompleted) { + data->OnSeekCompleted(); + data->_isSeekCompleted = true; + } + break; + default: + // For more GstMessage types see: + // https://gstreamer.freedesktop.org/documentation/gstreamer/gstmessage.html?gi-language=c#enumerations + break; + } + + // Continue watching for messages + return TRUE; +}; + +void AudioPlayer::OnMediaError(GError* error, gchar* debug) { + if (this->_eventChannel) { + gchar const* code = "LinuxAudioError"; + gchar const* message; + auto detailsStr = std::string(error->message) + " (Domain: " + + std::string(g_quark_to_string(error->domain)) + + ", Code: " + std::to_string(error->code) + ")"; + FlValue* details = fl_value_new_string(detailsStr.c_str()); + // https://gstreamer.freedesktop.org/documentation/gstreamer/gsterror.html#enumerations + if (error->domain == GST_STREAM_ERROR || + error->domain == GST_RESOURCE_ERROR) { + message = + "Failed to set source. For troubleshooting, " + "see: " STR_LINK_TROUBLESHOOTING; + } else { + message = "Unknown GstGError. See details."; + } + this->OnError(code, message, details, &error); + } +} + +void AudioPlayer::OnError(const gchar* code, + const gchar* message, + FlValue* details, + GError** error) { + if (this->_eventChannel) { + fl_event_channel_send_error(this->_eventChannel, code, message, details, + nullptr, error); + } else { + std::ostringstream oss; + oss << "Error: " << code << "; message=" << message; + g_print("%s\n", oss.str().c_str()); + } +} + +void AudioPlayer::OnMediaStateChange(GstObject* src, + GstState* old_state, + GstState* new_state) { + if (!playbin) { + this->OnError("LinuxAudioError", + "Player was already disposed (OnMediaStateChange).", nullptr, + nullptr); + return; + } + + if (src == GST_OBJECT(playbin)) { + if (*new_state == GST_STATE_READY) { + // Need to set to pause state, in order to make player functional + GstStateChangeReturn ret = + gst_element_set_state(playbin, GST_STATE_PAUSED); + if (ret == GST_STATE_CHANGE_FAILURE) { + // Only use [OnLog] as error is handled via [OnMediaError]. + gchar const* errorDescription = + "OnMediaStateChange -> GST_STATE_CHANGE_FAILURE:" + "Unable to set the pipeline from GST_STATE_READY to " + "GST_STATE_PAUSED."; + this->OnLog(errorDescription); + } + if (this->_isInitialized) { + this->_isInitialized = false; + } + } else if (*old_state == GST_STATE_PAUSED && + *new_state == GST_STATE_PLAYING) { + OnDurationUpdate(); + } else if (*new_state >= GST_STATE_PAUSED) { + if (!this->_isInitialized) { + this->_isInitialized = true; + this->OnPrepared(true); + if (this->_isPlaying) { + Resume(); + } + } + } else if (this->_isInitialized) { + this->_isInitialized = false; + } + } +} + +void AudioPlayer::OnPrepared(bool isPrepared) { + if (this->_eventChannel) { + g_autoptr(FlValue) map = fl_value_new_map(); + fl_value_set_string(map, "event", fl_value_new_string("audio.onPrepared")); + fl_value_set_string(map, "value", fl_value_new_bool(isPrepared)); + fl_event_channel_send(this->_eventChannel, map, nullptr, nullptr); + } +} + +void AudioPlayer::OnDurationUpdate() { + if (this->_eventChannel) { + g_autoptr(FlValue) map = fl_value_new_map(); + fl_value_set_string(map, "event", fl_value_new_string("audio.onDuration")); + fl_value_set_string(map, "value", + fl_value_new_int(GetDuration().value_or(0))); + fl_event_channel_send(this->_eventChannel, map, nullptr, nullptr); + } +} + +void AudioPlayer::OnSeekCompleted() { + if (this->_eventChannel) { + g_autoptr(FlValue) map = fl_value_new_map(); + fl_value_set_string(map, "event", + fl_value_new_string("audio.onSeekComplete")); + fl_value_set_string(map, "value", fl_value_new_bool(true)); + fl_event_channel_send(this->_eventChannel, map, nullptr, nullptr); + } +} + +void AudioPlayer::OnPlaybackEnded() { + if (this->_eventChannel) { + g_autoptr(FlValue) map = fl_value_new_map(); + fl_value_set_string(map, "event", fl_value_new_string("audio.onComplete")); + fl_value_set_string(map, "value", fl_value_new_bool(true)); + fl_event_channel_send(this->_eventChannel, map, nullptr, nullptr); + } + if (GetReleaseMode() == ReleaseMode::loop) { + Play(); + } else { + Stop(); + } +} + +void AudioPlayer::OnLog(const gchar* message) { + if (this->_eventChannel) { + g_autoptr(FlValue) map = fl_value_new_map(); + fl_value_set_string(map, "event", fl_value_new_string("audio.onLog")); + fl_value_set_string(map, "value", fl_value_new_string(message)); + + fl_event_channel_send(this->_eventChannel, map, nullptr, nullptr); + } +} + +void AudioPlayer::SetBalance(float balance) { + if (!panorama) { + this->OnLog("Audiopanorama was not initialized"); + return; + } + + if (balance > 1.0f) { + balance = 1.0f; + } else if (balance < -1.0f) { + balance = -1.0f; + } + g_object_set(G_OBJECT(panorama), "panorama", balance, NULL); +} + +void AudioPlayer::SetReleaseMode(ReleaseMode releaseMode) { + _releaseMode = releaseMode; +} + +ReleaseMode AudioPlayer::GetReleaseMode() { + return _releaseMode; +} + +void AudioPlayer::SetVolume(double volume) { + if (volume > 1) { + volume = 1; + } else if (volume < 0) { + volume = 0; + } + g_object_set(G_OBJECT(playbin), "volume", volume, NULL); +} + +/** + * A rate of 1.0 means normal playback rate, 2.0 means double speed. + * Negatives values means backwards playback. + * A value of 0.0 will pause the player. + * + * @param position the position in milliseconds + * @param rate the playback rate (speed) + */ +void AudioPlayer::SetPlayback(int64_t position, double rate) { + if (rate != 0 && _playbackRate != rate) { + _playbackRate = rate; + } + + if (!_isInitialized) { + return; + } + // See: + // https://gstreamer.freedesktop.org/documentation/tutorials/basic/playback-speed.html?gi-language=c + if (!_isSeekCompleted) { + return; + } + if (rate == 0) { + // Do not set rate if it's 0, rather pause. + Pause(); + return; + } + + _isSeekCompleted = false; + + GstEvent* seek_event; + if (rate > 0) { + seek_event = gst_event_new_seek( + rate, GST_FORMAT_TIME, + GstSeekFlags(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE), + GST_SEEK_TYPE_SET, position * GST_MSECOND, GST_SEEK_TYPE_NONE, -1); + } else { + seek_event = gst_event_new_seek( + rate, GST_FORMAT_TIME, + GstSeekFlags(GST_SEEK_FLAG_FLUSH | GST_SEEK_FLAG_ACCURATE), + GST_SEEK_TYPE_SET, 0, GST_SEEK_TYPE_SET, position * GST_MSECOND); + } + + if (!gst_element_send_event(playbin, seek_event)) { + this->OnLog((std::string("Could not set playback to position ") + + std::to_string(position) + std::string(" and rate ") + + std::to_string(rate) + std::string(".")) + .c_str()); + _isSeekCompleted = true; + } +} + +void AudioPlayer::SetPlaybackRate(double rate) { + SetPlayback(GetPosition().value_or(0), rate); +} + +/** + * @param position the position in milliseconds + */ +void AudioPlayer::SetPosition(int64_t position) { + if (!_isInitialized) { + return; + } + SetPlayback(position, _playbackRate); +} + +/** + * @return int64_t the position in milliseconds + */ +std::optional AudioPlayer::GetPosition() { + gint64 current = 0; + if (!gst_element_query_position(playbin, GST_FORMAT_TIME, ¤t)) { + this->OnLog("Could not query current position."); + return std::nullopt; + } + return std::make_optional(current / 1000000); +} + +/** + * @return int64_t the duration in milliseconds + */ +std::optional AudioPlayer::GetDuration() { + gint64 duration = 0; + if (!gst_element_query_duration(playbin, GST_FORMAT_TIME, &duration)) { + // FIXME: Get duration for MP3 with variable bit rate with gst-discoverer: + // https://gstreamer.freedesktop.org/documentation/pbutils/gstdiscoverer.html?gi-language=c#gst_discoverer_info_get_duration + this->OnLog("Could not query current duration."); + return std::nullopt; + } + return std::make_optional(duration / 1000000); +} + +void AudioPlayer::Play() { + SetPosition(0); + Resume(); +} + +void AudioPlayer::Pause() { + if (_isPlaying) { + _isPlaying = false; + } + if (!_isInitialized) { + return; + } + GstStateChangeReturn ret = gst_element_set_state(playbin, GST_STATE_PAUSED); + if (ret == GST_STATE_CHANGE_SUCCESS) { + } else if (ret == GST_STATE_CHANGE_FAILURE) { + throw "Unable to set the pipeline to GST_STATE_PAUSED."; + } +} + +void AudioPlayer::Stop() { + Pause(); + if (!_isInitialized) { + return; + } + + if (GetReleaseMode() == ReleaseMode::release) { + ReleaseMediaSource(); + } else { + SetPosition(0); + // Block thread to wait for state, as it is not expected to be waited to + // "seek complete" event on the dart side. + GstStateChangeReturn ret = + gst_element_get_state(playbin, NULL, NULL, GST_CLOCK_TIME_NONE); + if (ret == GST_STATE_CHANGE_FAILURE) { + throw "Unable to seek playback to '0' while stopping the player."; + } + } +} + +void AudioPlayer::Resume() { + if (!_isPlaying) { + _isPlaying = true; + } + if (!_isInitialized) { + return; + } + GstStateChangeReturn ret = gst_element_set_state(playbin, GST_STATE_PLAYING); + if (ret == GST_STATE_CHANGE_SUCCESS) { + // Update duration when start playing, as no event is emitted elsewhere + OnDurationUpdate(); + } else if (ret == GST_STATE_CHANGE_FAILURE) { + throw "Unable to set the pipeline to GST_STATE_PLAYING."; + } +} + +void AudioPlayer::Dispose() { + if (!playbin) + throw "Player was already disposed (Dispose)"; + ReleaseMediaSource(); + + if (bus) { + gst_bus_remove_watch(bus); + gst_object_unref(GST_OBJECT(bus)); + bus = nullptr; + } + + if (source) { + gst_object_unref(GST_OBJECT(source)); + source = nullptr; + } + + if (panorama) { + gst_element_set_state(audiobin, GST_STATE_NULL); + + gst_element_remove_pad(audiobin, panoramaSinkPad); + gst_bin_remove(GST_BIN(audiobin), audiosink); + gst_bin_remove(GST_BIN(audiobin), panorama); + + // audiobin gets unreferenced (2x) via playbin + panorama = nullptr; + } + + gst_object_unref(GST_OBJECT(playbin)); + // Do not dispose method channel as it is used by multiple players! + g_clear_object(&_eventChannel); + _eventChannel = nullptr; + playbin = nullptr; +} diff --git a/packages/audioplayers_linux/linux/audio_player.h b/packages/audioplayers_linux/linux/audio_player.h new file mode 100644 index 000000000..d657fe97b --- /dev/null +++ b/packages/audioplayers_linux/linux/audio_player.h @@ -0,0 +1,118 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +// STL headers +#include +#include +#include +#include +#include + +extern "C" { +#include +} + +enum ReleaseMode { stop, release, loop }; + +static std::unordered_map const releaseModeMap = { + {"ReleaseMode.stop", ReleaseMode::stop}, + {"ReleaseMode.release", ReleaseMode::release}, + {"ReleaseMode.loop", ReleaseMode::loop}}; + +class AudioPlayer { + public: + AudioPlayer(std::string playerId, + FlMethodChannel* methodChannel, + FlEventChannel* eventChannel); + + std::optional GetPosition(); + + std::optional GetDuration(); + + ReleaseMode GetReleaseMode(); + + void Play(); + + void Pause(); + + void Stop(); + + void Resume(); + + void Dispose(); + + void SetBalance(float balance); + + void SetReleaseMode(ReleaseMode releaseMode); + + void SetVolume(double volume); + + void SetPlaybackRate(double rate); + + void SetPosition(int64_t position); + + void SetSourceUrl(std::string url); + + void ReleaseMediaSource(); + + void OnError(const gchar* code, + const gchar* message, + FlValue* details, + GError** error); + + void OnLog(const gchar* message); + + virtual ~AudioPlayer(); + + private: + // Gst members + GstElement* playbin = nullptr; + GstElement* source = nullptr; + GstElement* panorama = nullptr; + GstElement* audiobin = nullptr; + GstElement* audiosink = nullptr; + GstPad* panoramaSinkPad = nullptr; + GstBus* bus = nullptr; + + bool _isInitialized = false; + bool _isPlaying = false; + ReleaseMode _releaseMode = ReleaseMode::release; + bool _isSeekCompleted = true; + double _playbackRate = 1.0; + + std::string _url{}; + std::string _playerId; + FlEventChannel* _eventChannel; + + static void SourceSetup(GstElement* playbin, + GstElement* source, + GstElement** p_src); + + static gboolean OnBusMessage(GstBus* bus, + GstMessage* message, + AudioPlayer* data); + + void SetPlayback(int64_t seekTo, double rate); + + void OnMediaError(GError* error, gchar* debug); + + void OnMediaStateChange(GstObject* src, + GstState* old_state, + GstState* new_state); + + void OnDurationUpdate(); + + void OnSeekCompleted(); + + void OnPlaybackEnded(); + + void OnPrepared(bool isPrepared); +}; \ No newline at end of file diff --git a/packages/audioplayers_linux/linux/audioplayers_linux_plugin.cc b/packages/audioplayers_linux/linux/audioplayers_linux_plugin.cc new file mode 100644 index 000000000..016e148b8 --- /dev/null +++ b/packages/audioplayers_linux/linux/audioplayers_linux_plugin.cc @@ -0,0 +1,328 @@ +#include "include/audioplayers_linux/audioplayers_linux_plugin.h" + +// This must be included before many other Windows headers. +// #include + +// For getPlatformVersion; remove unless needed for your plugin implementation. +// #include + +#include +#include + +#include +#include +#include + +#include "audio_player.h" + +#define AUDIOPLAYERS_LINUX_PLUGIN(obj) \ + (G_TYPE_CHECK_INSTANCE_CAST((obj), audioplayers_linux_plugin_get_type(), \ + AudioplayersLinuxPlugin)) + +struct _AudioplayersLinuxPlugin { + GObject parent_instance; +}; + +G_DEFINE_TYPE(AudioplayersLinuxPlugin, + audioplayers_linux_plugin, + g_object_get_type()) + +static FlBinaryMessenger* binaryMessenger; +static FlMethodChannel* methods; +static FlMethodChannel* globalMethods; +static FlEventChannel* globalEvents; +static std::map> audioPlayers; + +static void audioplayers_linux_plugin_create_player( + const std::string& playerId) { + g_autoptr(FlStandardMethodCodec) eventCodec = fl_standard_method_codec_new(); + auto eventChannel = fl_event_channel_new( + binaryMessenger, ("xyz.luan/audioplayers/events/" + playerId).c_str(), + FL_METHOD_CODEC(eventCodec)); + + auto player = std::make_unique(playerId, methods, eventChannel); + audioPlayers.insert(std::make_pair(playerId, std::move(player))); +} + +static AudioPlayer* audioplayers_linux_plugin_get_player( + const std::string& playerId) { + auto searchPlayer = audioPlayers.find(playerId); + if (searchPlayer == audioPlayers.end()) { + return nullptr; + } + return searchPlayer->second.get(); +} + +static void audioplayers_linux_plugin_on_global_log(const gchar* message) { + g_autoptr(FlValue) map = fl_value_new_map(); + fl_value_set_string(map, "event", fl_value_new_string("audio.onLog")); + fl_value_set_string(map, "value", fl_value_new_string(message)); + + fl_event_channel_send(globalEvents, map, nullptr, nullptr); +} + +static void audioplayers_linux_plugin_handle_global_method_call( + AudioplayersLinuxPlugin* self, + FlMethodCall* method_call) { + g_autoptr(FlMethodResponse) response = nullptr; + int result = 1; + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + if (strcmp(method, "init") == 0) { + for (const auto& entry : audioPlayers) { + entry.second->Dispose(); + } + audioPlayers.clear(); + } else if (strcmp(method, "setAudioContext") == 0) { + audioplayers_linux_plugin_on_global_log( + "Setting AudioContext is not supported on Linux"); + } else if (strcmp(method, "emitLog") == 0) { + auto flMessage = fl_value_lookup_string(args, "message"); + auto message = flMessage == nullptr ? "" : fl_value_get_string(flMessage); + audioplayers_linux_plugin_on_global_log(message); + } else if (strcmp(method, "emitError") == 0) { + auto flCode = fl_value_lookup_string(args, "code"); + auto code = flCode == nullptr ? "" : fl_value_get_string(flCode); + auto flMessage = fl_value_lookup_string(args, "message"); + auto message = flMessage == nullptr ? "" : fl_value_get_string(flMessage); + fl_event_channel_send_error(globalEvents, code, message, nullptr, nullptr, + nullptr); + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + fl_method_call_respond(method_call, response, nullptr); + return; + } + + response = FL_METHOD_RESPONSE( + fl_method_success_response_new(fl_value_new_int(result))); + fl_method_call_respond(method_call, response, nullptr); +} + +static void audioplayers_linux_plugin_handle_method_call( + AudioplayersLinuxPlugin* self, + FlMethodCall* method_call) { + g_autoptr(FlMethodResponse) response = nullptr; + const gchar* method = fl_method_call_get_name(method_call); + FlValue* args = fl_method_call_get_args(method_call); + + auto flPlayerId = fl_value_lookup_string(args, "playerId"); + if (flPlayerId == nullptr) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "LinuxAudioError", "Call missing mandatory parameter playerId.", + nullptr)); + fl_method_call_respond(method_call, response, nullptr); + return; + } + auto playerId = std::string(fl_value_get_string(flPlayerId)); + + if (strcmp(method, "create") == 0) { + audioplayers_linux_plugin_create_player(playerId); + response = + FL_METHOD_RESPONSE(fl_method_success_response_new(fl_value_new_int(1))); + fl_method_call_respond(method_call, response, nullptr); + return; + } + + auto player = audioplayers_linux_plugin_get_player(playerId); + if (!player) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "LinuxAudioError", + "Player has not yet been created or has already been disposed.", + nullptr)); + fl_method_call_respond(method_call, response, nullptr); + return; + } + + FlValue* result = nullptr; + + try { + if (strcmp(method, "pause") == 0) { + player->Pause(); + } else if (strcmp(method, "resume") == 0) { + player->Resume(); + } else if (strcmp(method, "stop") == 0) { + player->Stop(); + } else if (strcmp(method, "release") == 0) { + player->ReleaseMediaSource(); + } else if (strcmp(method, "seek") == 0) { + auto flPosition = fl_value_lookup_string(args, "position"); + int position = flPosition == nullptr + ? (int)(player->GetPosition().value_or(0)) + : fl_value_get_int(flPosition); + player->SetPosition(position); + } else if (strcmp(method, "setSourceUrl") == 0) { + auto flUrl = fl_value_lookup_string(args, "url"); + if (flUrl == nullptr) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "LinuxAudioError", "Null URL received on setSourceUrl.", nullptr)); + fl_method_call_respond(method_call, response, nullptr); + return; + } + auto url = std::string(fl_value_get_string(flUrl)); + + auto flIsLocal = fl_value_lookup_string(args, "isLocal"); + bool isLocal = + flIsLocal == nullptr ? false : fl_value_get_bool(flIsLocal); + if (isLocal) { + url = std::string("file://") + url; + } + player->SetSourceUrl(url); + } else if (strcmp(method, "getDuration") == 0) { + auto optDuration = player->GetDuration(); + result = optDuration.has_value() ? fl_value_new_int(optDuration.value()) + : nullptr; + } else if (strcmp(method, "setVolume") == 0) { + auto flVolume = fl_value_lookup_string(args, "volume"); + double volume = flVolume == nullptr ? 1.0 : fl_value_get_float(flVolume); + player->SetVolume(volume); + } else if (strcmp(method, "getCurrentPosition") == 0) { + auto optPosition = player->GetPosition(); + result = optPosition.has_value() ? fl_value_new_int(optPosition.value()) + : nullptr; + } else if (strcmp(method, "setPlaybackRate") == 0) { + auto flPlaybackRate = fl_value_lookup_string(args, "playbackRate"); + double playbackRate = + flPlaybackRate == nullptr ? 1.0 : fl_value_get_float(flPlaybackRate); + player->SetPlaybackRate(playbackRate); + } else if (strcmp(method, "setReleaseMode") == 0) { + auto flReleaseMode = fl_value_lookup_string(args, "releaseMode"); + std::string releaseModeStr = + flReleaseMode == nullptr + ? std::string() + : std::string(fl_value_get_string(flReleaseMode)); + if (releaseModeStr.empty()) { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "LinuxAudioError", + "Error calling setReleaseMode, releaseMode cannot be null", + nullptr)); + fl_method_call_respond(method_call, response, nullptr); + return; + } + + auto releaseModeIt = releaseModeMap.find(releaseModeStr); + if (releaseModeIt != releaseModeMap.end()) { + player->SetReleaseMode(releaseModeIt->second); + } else { + response = FL_METHOD_RESPONSE(fl_method_error_response_new( + "LinuxAudioError", + ("Error calling setReleaseMode, releaseMode '" + releaseModeStr + + "' not known") + .c_str(), + nullptr)); + fl_method_call_respond(method_call, response, nullptr); + return; + } + } else if (strcmp(method, "setPlayerMode") == 0) { + // TODO check support for low latency mode: + // https://gstreamer.freedesktop.org/documentation/additional/design/latency.html?gi-language=c + } else if (strcmp(method, "setAudioContext") == 0) { + player->OnLog("Setting AudioContext is not supported on Linux"); + } else if (strcmp(method, "setBalance") == 0) { + auto flBalance = fl_value_lookup_string(args, "balance"); + double balance = + flBalance == nullptr ? 0.0f : fl_value_get_float(flBalance); + player->SetBalance(balance); + } else if (strcmp(method, "emitLog") == 0) { + auto flMessage = fl_value_lookup_string(args, "message"); + auto message = flMessage == nullptr ? "" : fl_value_get_string(flMessage); + player->OnLog(message); + } else if (strcmp(method, "emitError") == 0) { + auto flCode = fl_value_lookup_string(args, "code"); + auto code = flCode == nullptr ? "" : fl_value_get_string(flCode); + auto flMessage = fl_value_lookup_string(args, "message"); + auto message = flMessage == nullptr ? "" : fl_value_get_string(flMessage); + player->OnError(code, message, nullptr, nullptr); + } else if (strcmp(method, "dispose") == 0) { + player->Dispose(); + audioPlayers.erase(playerId); + } else { + response = FL_METHOD_RESPONSE(fl_method_not_implemented_response_new()); + fl_method_call_respond(method_call, response, nullptr); + return; + } + + response = FL_METHOD_RESPONSE(fl_method_success_response_new(result)); + fl_method_call_respond(method_call, response, nullptr); + } catch (const gchar* error) { + response = FL_METHOD_RESPONSE( + fl_method_error_response_new("LinuxAudioError", error, nullptr)); + fl_method_call_respond(method_call, response, nullptr); + } catch (...) { + std::exception_ptr p = std::current_exception(); + response = FL_METHOD_RESPONSE( + fl_method_error_response_new("LinuxAudioError", + p ? p.__cxa_exception_type()->name() + : "Unknown AudioPlayersLinux error", + nullptr)); + fl_method_call_respond(method_call, response, nullptr); + } +} + +static void audioplayers_linux_plugin_dispose(GObject* object) { + for (const auto& entry : audioPlayers) { + entry.second->Dispose(); + } + audioPlayers.clear(); + gst_deinit(); + g_clear_object(&globalEvents); + g_clear_object(&globalMethods); + g_clear_object(&methods); + G_OBJECT_CLASS(audioplayers_linux_plugin_parent_class)->dispose(object); +} + +static void audioplayers_linux_plugin_class_init( + AudioplayersLinuxPluginClass* klass) { + G_OBJECT_CLASS(klass)->dispose = audioplayers_linux_plugin_dispose; +} + +static void audioplayers_linux_plugin_init(AudioplayersLinuxPlugin* self) {} + +static void method_call_cb(FlMethodChannel* methods, + FlMethodCall* method_call, + gpointer user_data) { + AudioplayersLinuxPlugin* plugin = AUDIOPLAYERS_LINUX_PLUGIN(user_data); + audioplayers_linux_plugin_handle_method_call(plugin, method_call); +} + +static void method_call_global_cb(FlMethodChannel* methods, + FlMethodCall* method_call, + gpointer user_data) { + AudioplayersLinuxPlugin* plugin = AUDIOPLAYERS_LINUX_PLUGIN(user_data); + audioplayers_linux_plugin_handle_global_method_call(plugin, method_call); +} + +void audioplayers_linux_plugin_register_with_registrar( + FlPluginRegistrar* registrar) { + AudioplayersLinuxPlugin* plugin = AUDIOPLAYERS_LINUX_PLUGIN( + g_object_new(audioplayers_linux_plugin_get_type(), nullptr)); + + binaryMessenger = fl_plugin_registrar_get_messenger(registrar); + + g_autoptr(FlStandardMethodCodec) methodCodec = fl_standard_method_codec_new(); + methods = fl_method_channel_new(binaryMessenger, "xyz.luan/audioplayers", + FL_METHOD_CODEC(methodCodec)); + + g_autoptr(FlStandardMethodCodec) globalMethodCodec = + fl_standard_method_codec_new(); + globalMethods = + fl_method_channel_new(binaryMessenger, "xyz.luan/audioplayers.global", + FL_METHOD_CODEC(globalMethodCodec)); + + g_autoptr(FlStandardMethodCodec) globalEventCodec = + fl_standard_method_codec_new(); + globalEvents = fl_event_channel_new(binaryMessenger, + "xyz.luan/audioplayers.global/events", + FL_METHOD_CODEC(globalEventCodec)); + + fl_method_channel_set_method_call_handler( + methods, method_call_cb, g_object_ref(plugin), g_object_unref); + + fl_method_channel_set_method_call_handler( + globalMethods, method_call_global_cb, g_object_ref(plugin), + g_object_unref); + + // No need to set handler for `globalEvents` as no events are received. + + g_object_unref(plugin); +} diff --git a/packages/audioplayers_linux/linux/include/audioplayers_linux/audioplayers_linux_plugin.h b/packages/audioplayers_linux/linux/include/audioplayers_linux/audioplayers_linux_plugin.h new file mode 100644 index 000000000..aa6a7b9a9 --- /dev/null +++ b/packages/audioplayers_linux/linux/include/audioplayers_linux/audioplayers_linux_plugin.h @@ -0,0 +1,26 @@ +#ifndef FLUTTER_PLUGIN_AUDIOPLAYERS_LINUX_PLUGIN_H_ +#define FLUTTER_PLUGIN_AUDIOPLAYERS_LINUX_PLUGIN_H_ + +#include + +G_BEGIN_DECLS + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __attribute__((visibility("default"))) +#else +#define FLUTTER_PLUGIN_EXPORT +#endif + +typedef struct _AudioplayersLinuxPlugin AudioplayersLinuxPlugin; +typedef struct { + GObjectClass parent_class; +} AudioplayersLinuxPluginClass; + +FLUTTER_PLUGIN_EXPORT GType audioplayers_linux_plugin_get_type(); + +FLUTTER_PLUGIN_EXPORT void audioplayers_linux_plugin_register_with_registrar( + FlPluginRegistrar* registrar); + +G_END_DECLS + +#endif // FLUTTER_PLUGIN_AUDIOPLAYERS_LINUX_PLUGIN_H_ diff --git a/packages/audioplayers_linux/pubspec.yaml b/packages/audioplayers_linux/pubspec.yaml new file mode 100644 index 000000000..5630e01e0 --- /dev/null +++ b/packages/audioplayers_linux/pubspec.yaml @@ -0,0 +1,27 @@ +name: audioplayers_linux +resolution: workspace +description: Linux implementation of audioplayers, a Flutter plugin to play multiple audio files simultaneously +version: 4.2.1 +homepage: https://github.com/bluefireteam/audioplayers +repository: https://github.com/bluefireteam/audioplayers/tree/master/packages/audioplayers_linux + +flutter: + plugin: + implements: audioplayers + platforms: + linux: + pluginClass: AudioplayersLinuxPlugin + +dependencies: + audioplayers_platform_interface: ^7.1.1 + flutter: + sdk: flutter + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + +environment: + sdk: ^3.6.0 + flutter: '>=3.27.0' diff --git a/packages/audioplayers_platform_interface/.gitignore b/packages/audioplayers_platform_interface/.gitignore new file mode 100644 index 000000000..04c2400af --- /dev/null +++ b/packages/audioplayers_platform_interface/.gitignore @@ -0,0 +1,17 @@ +*.iml +.DS_Store +.atom/ +.idea +.packages +.dart_tool/ +.pub/ +build/ +ios/.generated/ +packages +.classpath +.project +.settings +.vscode +testing +.flutter-plugins-dependencies +flutter_export_environment.sh diff --git a/packages/audioplayers_platform_interface/CHANGELOG.md b/packages/audioplayers_platform_interface/CHANGELOG.md new file mode 100644 index 000000000..237fbaa05 --- /dev/null +++ b/packages/audioplayers_platform_interface/CHANGELOG.md @@ -0,0 +1,106 @@ +## 7.1.1 + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +## 7.1.0 + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +## 7.0.1 + + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +## 7.0.0 + +> Note: This release has breaking changes. + + - **FEAT**: Support byte array and data URIs via mimeType ([#1763](https://github.com/bluefireteam/audioplayers/issues/1763)). ([eaf7ce86](https://github.com/bluefireteam/audioplayers/commit/eaf7ce86ad271097365fcf9e3a03fc341629ae47)) + - **FEAT**(ios): Improved AudioContextConfig assertions, fix example ([#1619](https://github.com/bluefireteam/audioplayers/issues/1619)). ([df342c52](https://github.com/bluefireteam/audioplayers/commit/df342c529b0b13abd0515c5dc762987293ebc4c1)) + - **DOCS**: Improve Docs ([#1710](https://github.com/bluefireteam/audioplayers/issues/1710)). ([4208463a](https://github.com/bluefireteam/audioplayers/commit/4208463a4110ed117eebe28e170872817712ff53)) + - **BREAKING** **FEAT**: Extend `AudioContextConfig.duckAudio` to `AudioContextConfig.focus` ([#1720](https://github.com/bluefireteam/audioplayers/issues/1720)). ([87f3cb7e](https://github.com/bluefireteam/audioplayers/commit/87f3cb7e47e2103d2079a3dfe6aebe80c8a76c3d)) + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **FEAT**(ios): Improve AudioContextIOS ([#1591](https://github.com/bluefireteam/audioplayers/issues/1591)). ([25fbec05](https://github.com/bluefireteam/audioplayers/commit/25fbec051a4f521f73c473cdad20f88c7907d7b1)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + +## 6.1.0 + + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + - **FEAT**: create, dispose & reuse event stream ([#1609](https://github.com/bluefireteam/audioplayers/issues/1609)). ([efbabf5c](https://github.com/bluefireteam/audioplayers/commit/efbabf5cb30de0013fe3b67cb7206de602f1dc84)) + +## 6.0.0 + +> Note: This release has breaking changes. + + - **FIX**(android): Allow AudioFocus.none ([#1534](https://github.com/bluefireteam/audioplayers/issues/1534)). ([858d3f44](https://github.com/bluefireteam/audioplayers/commit/858d3f4410b1bc7b203090c20cf60b5136dad4fe)) + - **BREAKING** **FIX**: Default audio output to system preferences ([#1563](https://github.com/bluefireteam/audioplayers/issues/1563)). ([381c43e3](https://github.com/bluefireteam/audioplayers/commit/381c43e3725fbb0cb4fd35982893a3c92b188886)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +## 5.0.1 + + - **FIX**: AudioEvent missing `isPrepared` logic ([#1521](https://github.com/bluefireteam/audioplayers/issues/1521)). ([1fa46c2c](https://github.com/bluefireteam/audioplayers/commit/1fa46c2cd28a4640c4aae65deee91ffe46cc4425)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + - **DOCS**: Improve doc for 'AudioContextConfig.respectSilence' ([#1490](https://github.com/bluefireteam/audioplayers/issues/1490)) ([#1500](https://github.com/bluefireteam/audioplayers/issues/1500)). ([415dda3b](https://github.com/bluefireteam/audioplayers/commit/415dda3b1621c57ea4b0366187f27f6a189555bf)) + +## 5.0.0 + +> Note: This release has breaking changes. + + - **FEAT**: replace `Platform.isX` with `defaultTargetPlatform` ([#1446](https://github.com/bluefireteam/audioplayers/issues/1446)). ([6cd5656c](https://github.com/bluefireteam/audioplayers/commit/6cd5656c0c5deaab1fb4af78a5b7632402c3a1d3)) + - **FEAT**: extract AudioContext from audio_context_config ([#1440](https://github.com/bluefireteam/audioplayers/issues/1440)). ([e59c3b9f](https://github.com/bluefireteam/audioplayers/commit/e59c3b9f07c1a72f9bf4e424fa3b011645f191d2)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **BREAKING** **REFACTOR**: prevent from confusing and conflicting class names ([#1465](https://github.com/bluefireteam/audioplayers/issues/1465)). ([7cdb8586](https://github.com/bluefireteam/audioplayers/commit/7cdb858605f24f0abd1a225e04922830233f3e96)) + - **BREAKING** **REFACTOR**: improve separation of global audioplayer interface ([#1443](https://github.com/bluefireteam/audioplayers/issues/1443)). ([c0b3f85c](https://github.com/bluefireteam/audioplayers/commit/c0b3f85c477f0313299cc2a2898840d6c7d8dcd9)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + - **BREAKING** **FEAT**: expose classes of package `audioplayers_platform_interface` ([#1442](https://github.com/bluefireteam/audioplayers/issues/1442)). ([a6f89be1](https://github.com/bluefireteam/audioplayers/commit/a6f89be181b7bd664eaf96cb9509bbc5adf5dbb9)) + +### Migration instructions + +| Before | After | +|---|---| +| `LogLevel` | _moved_ to `audioplayers` package as `AudioLogLevel` | +| `AudioplayersPlatform` | `AudioplayersPlatformInterface` | +| `MethodChannelAudioplayersPlatform` | `AudioplayersPlatform` | +| `GlobalPlatformInterface` | `GlobalAudioplayersPlatformInterface` | +| `MethodChannelGlobalPlatform` | `GlobalAudioplayersPlatform` | +| `StreamsInterface` | _removed_ | +| `ForPlayer<>` | _removed_ | + +## 4.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **REFACTOR**: rename logger_platform_interface.dart to global_platform_interface.dart ([#1385](https://github.com/bluefireteam/audioplayers/issues/1385)). ([6e837c1c](https://github.com/bluefireteam/audioplayers/commit/6e837c1ccd93b95d10843a403674128cf303c0ab)) + - **BREAKING** **FEAT**: configurable SoundPool and `AudioManager.mode` ([#1388](https://github.com/bluefireteam/audioplayers/issues/1388)). ([5697f187](https://github.com/bluefireteam/audioplayers/commit/5697f187bcca64de2e519f8f49aaf4817fcf6398)) + +## 3.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **FIX**: remove unused `defaultToSpeaker` in `AudioContextIOS` and replace with `AVAudioSessionOptions.defaultToSpeaker` ([#1374](https://github.com/bluefireteam/audioplayers/issues/1374)). ([d844ef9d](https://github.com/bluefireteam/audioplayers/commit/d844ef9def06fd5047076d9f4c371ad3be4c8dd5)) + +## 2.1.0 + +> Note: This release has breaking changes. + + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + - **BREAKING** **FIX**: Change the default value of iOS audio context to force speakers ([#1363](https://github.com/bluefireteam/audioplayers/issues/1363)). ([cb16c12d](https://github.com/bluefireteam/audioplayers/commit/cb16c12d35655bbde5cd94ae1d6f2a03fd6eba1e)) + +## 2.0.0 + +> Note: This release has breaking changes. + + - **FIX**: handle platform exception via logger (#1254). ([56df6edf](https://github.com/bluefireteam/audioplayers/commit/56df6edfa1475e471c322c1180fd6f47d99c6610)) + - **BREAKING** **REFACTOR**: remove unused playerStateStream (#1280). ([27f9de22](https://github.com/bluefireteam/audioplayers/commit/27f9de224c7bc1f948356e917bf8b9c411fe9742)) + +## 1.0.0 + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +## 1.0.0-rc.2 + +## 1.0.0-rc.1 + + - First release after federation + diff --git a/packages/audioplayers_platform_interface/LICENSE b/packages/audioplayers_platform_interface/LICENSE new file mode 100644 index 000000000..1a581b05c --- /dev/null +++ b/packages/audioplayers_platform_interface/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audioplayers_platform_interface/README.md b/packages/audioplayers_platform_interface/README.md new file mode 100644 index 000000000..3aa45282d --- /dev/null +++ b/packages/audioplayers_platform_interface/README.md @@ -0,0 +1,29 @@ +

+ + AudioPlayers + +

+ +--- + +# audioplayers_platform_interface +

+ + + + +

+ +A common platform interface for the [`audioplayers`](https://pub.dev/packages/audioplayers) plugin. + +## Usage + +This package will be automatically included in your app, +which means you can simply use `audioplayers` normally, without adding this package to your `pubspec.yaml`. + +To implement a new platform-specific implementation of `audioplayers`, extend +[`AudioplayersPlatformInterface`](lib/src/audioplayers_platform_interface.dart) +with an implementation that performs the platform-specific behavior. +When you register your plugin, set the default +`AudioplayersPlatformInterface` by calling `AudioplayersPlatformInterface.instance = MyAudioplayersPlatform()`. +Then do the same for [`GlobalAudioplayersPlatformInterface`](lib/src/global_audioplayers_platform_interface.dart). diff --git a/packages/audioplayers_platform_interface/analysis_options.yaml b/packages/audioplayers_platform_interface/analysis_options.yaml new file mode 100644 index 000000000..85732fa02 --- /dev/null +++ b/packages/audioplayers_platform_interface/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/audioplayers_platform_interface/lib/audioplayers_platform_interface.dart b/packages/audioplayers_platform_interface/lib/audioplayers_platform_interface.dart new file mode 100644 index 000000000..5bdd6b374 --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/audioplayers_platform_interface.dart @@ -0,0 +1,8 @@ +export 'src/api/audio_context.dart'; +export 'src/api/audio_event.dart'; +export 'src/api/global_audio_event.dart'; +export 'src/api/player_mode.dart'; +export 'src/api/player_state.dart'; +export 'src/api/release_mode.dart'; +export 'src/audioplayers_platform_interface.dart'; +export 'src/global_audioplayers_platform_interface.dart'; diff --git a/packages/audioplayers_platform_interface/lib/src/api/audio_context.dart b/packages/audioplayers_platform_interface/lib/src/api/audio_context.dart new file mode 100644 index 000000000..bfdf4f085 --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/api/audio_context.dart @@ -0,0 +1,602 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/foundation.dart'; + +/// An Audio Context is a set of secondary, platform-specific aspects of audio +/// playback, typically related to how the act of playing audio interacts with +/// other features of the device. [AudioContext] is containing platform specific +/// configurations: [AudioContextAndroid] and [AudioContextIOS]. +@immutable +class AudioContext { + final AudioContextAndroid android; + late final AudioContextIOS iOS; + + AudioContext({ + AudioContextAndroid? android, + AudioContextIOS? iOS, + }) : android = android ?? const AudioContextAndroid() { + this.iOS = iOS ?? AudioContextIOS(); + } + + AudioContext copy({ + AudioContextAndroid? android, + AudioContextIOS? iOS, + }) { + return AudioContext( + android: android ?? this.android, + iOS: iOS ?? this.iOS, + ); + } + + Map toJson() { + // we need to check web first because `defaultTargetPlatform` is not + // available for web. + if (kIsWeb) { + return {}; + } else if (defaultTargetPlatform == TargetPlatform.android) { + return android.toJson(); + } else if (defaultTargetPlatform == TargetPlatform.iOS) { + return iOS.toJson(); + } else { + return {}; + } + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is AudioContext && + runtimeType == other.runtimeType && + android == other.android && + iOS == other.iOS; + } + + @override + int get hashCode => Object.hash( + android, + iOS, + ); + + @override + String toString() { + return 'AudioContext(' + 'android: $android, ' + 'iOS: $iOS' + ')'; + } +} + +/// A platform-specific class to encapsulate a collection of attributes about an +/// Android audio stream. +@immutable +class AudioContextAndroid { + /// Sets the speakerphone on or off, globally. + /// + /// This method should only be used by applications that replace the + /// platform-wide management of audio settings or the main telephony + /// application. + final bool isSpeakerphoneOn; + + /// Sets the audio mode, globally. + /// + /// This method should only be used by applications that replace the + /// platform-wide management of audio settings or the main telephony + /// application, see [AndroidAudioMode]. + final AndroidAudioMode audioMode; + + final bool stayAwake; + final AndroidContentType contentType; + final AndroidUsageType usageType; + final AndroidAudioFocus audioFocus; + + // Note when changing the defaults, it should also be changed in native code. + const AudioContextAndroid({ + this.isSpeakerphoneOn = false, + this.audioMode = AndroidAudioMode.normal, + this.stayAwake = false, + this.contentType = AndroidContentType.music, + this.usageType = AndroidUsageType.media, + this.audioFocus = AndroidAudioFocus.gain, + }); + + AudioContextAndroid copy({ + bool? isSpeakerphoneOn, + AndroidAudioMode? audioMode, + bool? stayAwake, + AndroidContentType? contentType, + AndroidUsageType? usageType, + AndroidAudioFocus? audioFocus, + }) { + return AudioContextAndroid( + isSpeakerphoneOn: isSpeakerphoneOn ?? this.isSpeakerphoneOn, + audioMode: audioMode ?? this.audioMode, + stayAwake: stayAwake ?? this.stayAwake, + contentType: contentType ?? this.contentType, + usageType: usageType ?? this.usageType, + audioFocus: audioFocus ?? this.audioFocus, + ); + } + + Map toJson() { + return { + 'isSpeakerphoneOn': isSpeakerphoneOn, + 'audioMode': audioMode.value, + 'stayAwake': stayAwake, + 'contentType': contentType.value, + 'usageType': usageType.value, + 'audioFocus': audioFocus.value, + }; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is AudioContextAndroid && + runtimeType == other.runtimeType && + isSpeakerphoneOn == other.isSpeakerphoneOn && + audioMode == other.audioMode && + stayAwake == other.stayAwake && + contentType == other.contentType && + usageType == other.usageType && + audioFocus == other.audioFocus; + } + + @override + int get hashCode => Object.hash( + isSpeakerphoneOn, + audioMode, + stayAwake, + contentType, + usageType, + audioFocus, + ); + + @override + String toString() { + return 'AudioContextAndroid(' + 'isSpeakerphoneOn: $isSpeakerphoneOn, ' + 'audioMode: $audioMode, ' + 'stayAwake: $stayAwake, ' + 'contentType: $contentType, ' + 'usageType: $usageType, ' + 'audioFocus: $audioFocus' + ')'; + } +} + +/// A platform-specific class to encapsulate a collection of attributes about an +/// iOS audio stream. +@immutable +class AudioContextIOS { + final AVAudioSessionCategory category; + final Set options; + + // Note when changing the defaults, it should also be changed in native code. + AudioContextIOS({ + this.category = AVAudioSessionCategory.playback, + this.options = const {}, + }) : assert( + category == AVAudioSessionCategory.playback || + category == AVAudioSessionCategory.playAndRecord || + category == AVAudioSessionCategory.multiRoute || + !options.contains(AVAudioSessionOptions.mixWithOthers), + 'You can set the option `mixWithOthers` explicitly only if the ' + 'audio session category is `playAndRecord`, `playback`, or ' + '`multiRoute`.'), + assert( + category == AVAudioSessionCategory.playback || + category == AVAudioSessionCategory.playAndRecord || + category == AVAudioSessionCategory.multiRoute || + !options.contains(AVAudioSessionOptions.duckOthers), + 'You can set the option `duckOthers` explicitly only if the audio ' + 'session category is `playAndRecord`, `playback`, or `multiRoute`.', + ), + assert( + category == AVAudioSessionCategory.playback || + category == AVAudioSessionCategory.playAndRecord || + category == AVAudioSessionCategory.multiRoute || + !options.contains( + AVAudioSessionOptions.interruptSpokenAudioAndMixWithOthers, + ), + 'You can set the option `interruptSpokenAudioAndMixWithOthers` ' + 'explicitly only if the audio session category is `playAndRecord`, ' + '`playback`, or `multiRoute`.'), + assert( + category == AVAudioSessionCategory.playAndRecord || + category == AVAudioSessionCategory.record || + !options.contains(AVAudioSessionOptions.allowBluetooth), + 'You can set the option `allowBluetooth` explicitly only if the ' + 'audio session category is `playAndRecord` or `record`.'), + assert( + category == AVAudioSessionCategory.playAndRecord || + category == AVAudioSessionCategory.record || + category == AVAudioSessionCategory.multiRoute || + !options.contains(AVAudioSessionOptions.allowBluetoothA2DP), + 'You can set the option `allowBluetoothA2DP` explicitly only if ' + 'the audio session category is `playAndRecord`, `record`, or ' + '`multiRoute`.'), + assert( + category == AVAudioSessionCategory.playAndRecord || + !options.contains(AVAudioSessionOptions.allowAirPlay), + 'You can set the option `allowAirPlay` explicitly only if the ' + 'audio session category is `playAndRecord`.'), + assert( + category == AVAudioSessionCategory.playAndRecord || + !options.contains(AVAudioSessionOptions.defaultToSpeaker), + 'You can set the option `defaultToSpeaker` explicitly only if the ' + 'audio session category is `playAndRecord`.'), + assert( + category == AVAudioSessionCategory.playAndRecord || + category == AVAudioSessionCategory.record || + category == AVAudioSessionCategory.multiRoute || + !options.contains( + AVAudioSessionOptions.overrideMutedMicrophoneInterruption, + ), + 'You can set the option `overrideMutedMicrophoneInterruption` ' + 'explicitly only if the audio session category is `playAndRecord`, ' + '`record`, or `multiRoute`.'); + + AudioContextIOS copy({ + AVAudioSessionCategory? category, + Set? options, + }) { + return AudioContextIOS( + category: category ?? this.category, + options: options ?? this.options, + ); + } + + Map toJson() { + return { + 'category': category.name, + 'options': options.map((e) => e.name).toList(), + }; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is AudioContextIOS && + runtimeType == other.runtimeType && + category == other.category && + const SetEquality().equals(options, other.options); + } + + @override + int get hashCode => Object.hash( + category, + options, + ); + + @override + String toString() { + return 'AudioContextIOS(' + 'category: $category, ' + 'options: $options' + ')'; + } +} + +/// "what" you are playing. The content type expresses the general category of +/// the content. This information is optional. But in case it is known (for +/// instance [movie] for a movie streaming service or [music] for a music +/// playback application) this information might be used by the audio framework +/// to selectively configure some audio post-processing blocks. +enum AndroidContentType { + /// Content type value to use when the content type is unknown, or other than + /// the ones defined. + unknown(0), + + /// Content type value to use when the content type is speech. + speech(1), + + /// Content type value to use when the content type is music. + music(2), + + /// Content type value to use when the content type is a soundtrack, typically + /// accompanying a movie or TV program. + movie(3), + + /// Content type value to use when the content type is a sound used to + /// accompany a user action, such as a beep or sound effect expressing a key + /// click, or event, such as the type of a sound for a bonus being received in + /// a game. These sounds are mostly synthesized or short Foley sounds. + sonification(4); + + const AndroidContentType(this.value); + + factory AndroidContentType.fromInt(int value) { + return values.firstWhere((e) => e.value == value); + } + + final int value; +} + +/// "why" you are playing a sound, what is this sound used for. This is achieved +/// with the "usage" information. Examples of usage are [media] and [alarm]. +/// These two examples are the closest to stream types, but more detailed use +/// cases are available. Usage information is more expressive than a stream +/// type, and allows certain platforms or routing policies to use this +/// information for more refined volume or routing decisions. Usage is the most +/// important information to supply in [AudioContextAndroid] and it is +/// recommended to build any instance with this information supplied. +enum AndroidUsageType { + /// Usage value to use when the usage is unknown. + unknown(0), + + /// Usage value to use when the usage is media, such as music, or movie + /// soundtracks. + media(1), + + /// Usage value to use when the usage is voice communications, such as + /// telephony or VoIP. + voiceCommunication(2), + + /// Usage value to use when the usage is in-call signalling, such as with a + /// "busy" beep, or DTMF tones. + voiceCommunicationSignalling(3), + + /// Usage value to use when the usage is an alarm (e.g. wake-up alarm). + alarm(4), + + /// Usage value to use when the usage is notification. See other notification + /// usages for more specialized uses. + notification(5), + + /// Usage value to use when the usage is telephony ringtone. + notificationRingtone(6), + + /// Usage value to use when the usage is a request to enter/end a + /// communication, such as a VoIP communication or video-conference. + notificationCommunicationRequest(7), + + /// Usage value to use when the usage is notification for an "instant" + /// communication such as a chat, or SMS. + notificationCommunicationInstant(8), + + /// Usage value to use when the usage is notification for a non-immediate type + /// of communication such as e-mail. + notificationCommunicationDelayed(9), + + /// Usage value to use when the usage is to attract the user's attention, such + /// as a reminder or low battery warning. + notificationEvent(10), + + /// Usage value to use when the usage is for accessibility, such as with a + /// screen reader. + assistanceAccessibility(11), + + /// Usage value to use when the usage is driving or navigation directions. + assistanceNavigationGuidance(12), + + /// Usage value to use when the usage is sonification, such as with user + /// interface sounds. + assistanceSonification(13), + + /// Usage value to use when the usage is for game audio. + game(14), + + /// @hide + /// + /// Usage value to use when feeding audio to the platform and replacing + /// "traditional" audio source, such as audio capture devices. + virtualSource(15), + + /// Usage value to use for audio responses to user queries, audio instructions + /// or help utterances. + assistant(16); + + const AndroidUsageType(this.value); + + factory AndroidUsageType.fromInt(int value) { + return values.firstWhere((e) => e.value == value); + } + + final int value; +} + +/// There are four focus request types. A successful focus request with each +/// will yield different behaviors by the system and the other application that +/// previously held audio focus. +/// See https://developer.android.com/reference/android/media/AudioFocusRequest +enum AndroidAudioFocus { + /// AudioManager#AUDIOFOCUS_NONE expresses that your app requests no audio + /// focus. + /// NOTE: Here it is used as replacement for an AudioFocus set to null, to + /// make it more convenient to unset the focus again. + /// Despite to the docs, AUDIOFOCUS_NONE is already present at API level 19. + /// https://developer.android.com/reference/android/media/AudioManager#AUDIOFOCUS_NONE + none(0), + + /// AudioManager#AUDIOFOCUS_GAIN expresses the fact that your application is + /// now the sole source of audio that the user is listening to. + /// The duration of the audio playback is unknown, and is possibly very long: + /// after the user finishes interacting with your application, (s)he doesn't + /// expect another audio stream to resume. Examples of uses of this focus gain + /// are for music playback, for a game or a video player. + gain(1), + + /// AudioManager#AUDIOFOCUS_GAIN_TRANSIENT is for a situation when you know + /// your application is temporarily grabbing focus from the current owner, + /// but the user expects playback to go back to where it was once your + /// application no longer requires audio focus. An example is for playing an + /// alarm, or during a VoIP call. The playback is known to be finite: + /// the alarm will time-out or be dismissed, the VoIP call has a beginning and + /// an end. When any of those events ends, and if the user was listening to + /// music when it started, the user expects music to resume, but didn't wish + /// to listen to both at the same time. + gainTransient(2), + + /// AudioManager#AUDIOFOCUS_GAIN_TRANSIENT_MAY_DUCK: this focus request type + /// is similar to AUDIOFOCUS_GAIN_TRANSIENT for the temporary aspect of the + /// focus request, but it also expresses the fact during the time you own + /// focus, you allow another application to keep playing at a reduced volume, + /// "ducked". Examples are when playing driving directions or notifications, + /// it's ok for music to keep playing, but not loud enough that it would + /// prevent the directions to be hard to understand. A typical attenuation by + /// the "ducked" application is a factor of 0.2f (or -14dB), that can for + /// instance be applied with MediaPlayer.setVolume(0.2f) when using this class + /// for playback. + gainTransientMayDuck(3), + + /// AudioManager#AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE is also for a temporary + /// request, but also expresses that your application expects the device to + /// not play anything else. This is typically used if you are doing audio + /// recording or speech recognition, and don't want for examples notifications + /// to be played by the system during that time. + gainTransientExclusive(4); + + const AndroidAudioFocus(this.value); + + factory AndroidAudioFocus.fromInt(int value) { + return values.firstWhere((e) => e.value == value); + } + + final int value; +} + +/// The audio mode encompasses audio routing AND the behavior of the telephony +/// layer. Therefore this flag should only be used by applications that +/// replace the platform-wide management of audio settings or the main telephony +/// application. In particular, the [inCall] mode should only be used by the +/// telephony application when it places a phone call, as it will cause signals +/// from the radio layer to feed the platform mixer. +enum AndroidAudioMode { + /// Normal audio mode: not ringing and no call established. + normal(0), + + /// Ringing audio mode. An incoming is being signaled. + ringtone(1), + + /// In call audio mode. A telephony call is established. + inCall(2), + + /// In communication audio mode. An audio/video chat or VoIP call is established. + inCommunication(3), + + /// Call screening in progress. Call is connected and audio is accessible to + /// call screening applications but other audio use cases are still possible. + callScreening(4); + + const AndroidAudioMode(this.value); + + factory AndroidAudioMode.fromInt(int value) { + return values.firstWhere((e) => e.value == value); + } + + final int value; +} + +/// This is a Dart representation of the equivalent enum on Swift. +/// +/// Audio session category identifiers. +/// An audio session category defines a set of audio behaviors. +/// Choose a category that most accurately describes the audio behavior you +/// require. +enum AVAudioSessionCategory { + /// Silenced by the Ring/Silent switch and by screen locking = Yes + /// Interrupts nonmixable app’s audio = No + /// Output only + ambient, + + /// Silenced by the Ring/Silent switch and by screen locking = Yes + /// Interrupts nonmixable app’s audio = Yes + /// Output only + /// This is the platform's default (not AP's default witch is playAndRecord). + soloAmbient, + + /// Silenced by the Ring/Silent switch and by screen locking = No + /// Interrupts nonmixable app’s audio = Yes by default; no by using override + /// switch. + /// Note: the switch is the `.mixWithOthers` option + /// (+ other options like `.duckOthers`). + /// Output only + playback, + + /// Silenced by the Ring/Silent switch and by screen locking = No (recording + /// continues with screen locked) + /// Interrupts nonmixable app’s audio = Yes + /// Input only + record, + + /// Silenced by the Ring/Silent switch and by screen locking = No + /// Interrupts nonmixable app’s audio = Yes by default; no by using override + /// switch. + /// Note: the switch is the `.mixWithOthers` option + /// (+ other options like `.duckOthers`). + /// Input and output + playAndRecord, + + /// Silenced by the Ring/Silent switch and by screen locking = No + /// Interrupts nonmixable app’s audio = Yes + /// Input and output + multiRoute, +} + +/// This is a Dart representation of the equivalent enum on Swift. +/// +/// Constants that specify optional audio behaviors. Each option is valid only +/// for specific audio session categories. +enum AVAudioSessionOptions { + /// An option that indicates whether audio from this session mixes with audio + /// from active sessions in other audio apps. + /// You can set this option explicitly only if the audio session category is + /// `playAndRecord`, `playback`, or `multiRoute`. + /// If you set the audio session category to `ambient`, the session + /// automatically sets this option. Likewise, setting the `duckOthers` or + /// `interruptSpokenAudioAndMixWithOthers` options also enables this option. + /// See: https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions/1616611-mixwithothers + mixWithOthers, + + /// An option that reduces the volume of other audio sessions while audio from + /// this session plays. + /// You can set this option only if the audio session category is + /// `playAndRecord`, `playback`, or `multiRoute`. + /// Setting it implicitly sets the `mixWithOthers` option. + /// https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions/1616618-duckothers + duckOthers, + + /// An option that determines whether to pause spoken audio content from other + /// sessions when your app plays its audio. + /// You can set this option only if the audio session category is + /// `playAndRecord`, `playback`, or `multiRoute`. Setting this option also + /// sets `mixWithOthers`. + /// See: https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions/1616534-interruptspokenaudioandmixwithot + interruptSpokenAudioAndMixWithOthers, + + /// An option that determines whether Bluetooth hands-free devices appear as + /// available input routes. + /// You can set this option only if the audio session category is + /// `playAndRecord` or `record`. + /// See: https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions/1616518-allowbluetooth + allowBluetooth, + + /// An option that determines whether you can stream audio from this session + /// to Bluetooth devices that support the Advanced Audio Distribution Profile + /// (A2DP). + /// The system automatically routes to A2DP ports if you configure an app’s + /// audio session to use the `ambient`, `soloAmbient`, or `playback` + /// categories. + /// See: https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions/1771735-allowbluetootha2dp + allowBluetoothA2DP, + + /// An option that determines whether you can stream audio from this session + /// to AirPlay devices. + /// You can only explicitly set this option if the audio session’s category is + /// set to `playAndRecord`. + /// See: https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions/1771736-allowairplay + allowAirPlay, + + /// An option that determines whether audio from the session defaults to the + /// built-in speaker instead of the receiver. + /// You can set this option only when using the `playAndRecord` category. + /// See: https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions/1616462-defaulttospeaker + defaultToSpeaker, + + /// An option that indicates whether the system interrupts the audio session + /// when it mutes the built-in microphone. + /// If your app uses an audio session category that supports input and output, + /// such as `playAndRecord`, you can set this option to disable the default + /// behavior and continue using the session. + /// See: https://developer.apple.com/documentation/avfaudio/avaudiosession/categoryoptions/3727255-overridemutedmicrophoneinterrupt + overrideMutedMicrophoneInterruption, +} diff --git a/packages/audioplayers_platform_interface/lib/src/api/audio_context_config.dart b/packages/audioplayers_platform_interface/lib/src/api/audio_context_config.dart new file mode 100644 index 000000000..15e6b94d0 --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/api/audio_context_config.dart @@ -0,0 +1,205 @@ +import 'package:audioplayers_platform_interface/src/api/audio_context.dart'; +import 'package:flutter/foundation.dart'; + +/// This class contains flags to control several secondary, platform-specific +/// aspects of audio playback, like how this audio interact with other audios, +/// how is it played by the device and what happens when the app is +/// backgrounded. +/// However, note that each platform has its nuances on how to configure audio. +/// This class is a generic abstraction of some parameters that can be useful +/// across the board. +/// Its flags are simple abstractions that are then translated to an +/// [AudioContext] containing platform specific configurations: +/// [AudioContextAndroid] and [AudioContextIOS]. +/// If these simplified flags cannot fully reflect your goals, you must create +/// an [AudioContext] configuring each platform separately. +class AudioContextConfig { + /// Normally, audio played will respect the devices configured preferences. + /// However, if you want to bypass that and flag the system to use the + /// built-in speakers or the earpiece, you can set this flag. + /// See [AudioContextConfigRoute] for more details on the options. + final AudioContextConfigRoute route; + + /// This flag determines how your audio interacts with other audio playing on + /// the device. + final AudioContextConfigFocus focus; + + /// Whether the "silent" mode of the device should be respected. + /// + /// When `false` (the default), audio will be played even if the device is in + /// silent mode. + /// When `true` and the device is in silent mode, audio will not be played. + /// + /// On Android, this will mandate the `USAGE_NOTIFICATION_RINGTONE` usage + /// type. + /// + /// On iOS, setting this mandates the [AVAudioSessionCategory.ambient] + /// category, and it will be: + /// * silenced by rings + /// * silenced by the Silent switch + /// * silenced by screen locking (note: read [stayAwake] for details on + /// this). + final bool respectSilence; + + /// By default, when the screen is locked, all the app's processing stops, + /// including audio playback. + /// You can set this flag to keep your audio playing even when locked. + /// + /// On Android, this sets the player "wake mode" to `PARTIAL_WAKE_LOCK`. + /// + /// On iOS, this will happen automatically as long as: + /// * the category is [AVAudioSessionCategory.playAndRecord] (thus setting + /// this is forbidden when [respectSilence] is set) + /// * the UIBackgroundModes audio key has been added to your app’s + /// Info.plist (check our FAQ for more details on that) + final bool stayAwake; + + AudioContextConfig({ + this.route = AudioContextConfigRoute.system, + this.focus = AudioContextConfigFocus.gain, + this.respectSilence = false, + this.stayAwake = false, + }); + + AudioContextConfig copy({ + AudioContextConfigRoute? route, + AudioContextConfigFocus? focus, + bool? respectSilence, + bool? stayAwake, + }) { + return AudioContextConfig( + route: route ?? this.route, + focus: focus ?? this.focus, + respectSilence: respectSilence ?? this.respectSilence, + stayAwake: stayAwake ?? this.stayAwake, + ); + } + + AudioContext build() { + return AudioContext( + android: buildAndroid(), + iOS: buildIOS(), + ); + } + + AudioContextAndroid buildAndroid() { + return AudioContextAndroid( + isSpeakerphoneOn: route == AudioContextConfigRoute.speaker, + stayAwake: stayAwake, + usageType: respectSilence + ? AndroidUsageType.notificationRingtone + : (route == AudioContextConfigRoute.earpiece + ? AndroidUsageType.voiceCommunication + : AndroidUsageType.media), + audioFocus: focus == AudioContextConfigFocus.gain + ? AndroidAudioFocus.gain + : (focus == AudioContextConfigFocus.duckOthers + ? AndroidAudioFocus.gainTransientMayDuck + : AndroidAudioFocus.none), + ); + } + + AudioContextIOS? buildIOS() { + if (defaultTargetPlatform != TargetPlatform.iOS) { + return null; + } + validateIOS(); + return AudioContextIOS( + category: respectSilence + ? AVAudioSessionCategory.ambient + : (route == AudioContextConfigRoute.speaker + ? AVAudioSessionCategory.playAndRecord + : (route == AudioContextConfigRoute.earpiece + ? AVAudioSessionCategory.playAndRecord + : AVAudioSessionCategory.playback)), + options: { + if (focus == AudioContextConfigFocus.duckOthers) + AVAudioSessionOptions.duckOthers, + if (focus == AudioContextConfigFocus.mixWithOthers) + AVAudioSessionOptions.mixWithOthers, + if (route == AudioContextConfigRoute.speaker) + AVAudioSessionOptions.defaultToSpeaker, + }, + ); + } + + void validateIOS() { + const invalidMsg = + 'Invalid AudioContextConfig: On iOS it is not possible to set'; + const tip = 'Please create a custom [AudioContextIOS] if the generic flags ' + 'cannot represent your needs.'; + assert( + !(respectSilence && focus == AudioContextConfigFocus.duckOthers), + '$invalidMsg `respectSilence` and `duckOthers`. $tip', + ); + assert( + !(respectSilence && focus == AudioContextConfigFocus.mixWithOthers), + '$invalidMsg `respectSilence` and `mixWithOthers`. $tip', + ); + assert( + !(respectSilence && route == AudioContextConfigRoute.speaker), + '$invalidMsg `respectSilence` and route `speaker`. $tip', + ); + } + + @override + String toString() { + return 'AudioContextConfig(' + 'route: $route, ' + 'focus: $focus, ' + 'respectSilence: $respectSilence, ' + 'stayAwake: $stayAwake' + ')'; + } +} + +enum AudioContextConfigRoute { + /// Use the system's default route. This can be e.g. the built-in speaker, the + /// earpiece, or a bluetooth device. + system, + + /// On Android, this will set the usageType + /// [AndroidUsageType.voiceCommunication]. + /// + /// On iOS, this will set the category [AVAudioSessionCategory.playAndRecord]. + earpiece, + + /// On Android, this will set [AudioContextAndroid.isSpeakerphoneOn] to true. + /// + /// On iOS, this will set the option [AVAudioSessionOptions.defaultToSpeaker]. + /// Note that this forces the category to be + /// [AVAudioSessionCategory.playAndRecord], and thus is forbidden when + /// [AudioContextConfig.respectSilence] is set. + speaker, +} + +enum AudioContextConfigFocus { + /// An option that expresses the fact that your application is + /// now the sole source of audio that the user is listening to. + /// + /// On Android, this will set the focus [AndroidAudioFocus.gain]. + /// + /// On iOS, this will not set any additional [AVAudioSessionOptions]. + gain, + + /// An option that reduces the volume of other audio sessions while audio from + /// this session (like an alarm, gps, etc.) plays on top. + /// + /// On Android, this will make an Audio Focus request with + /// [AndroidAudioFocus.gainTransientMayDuck] when your audio starts playing. + /// + /// On iOS, this will set the option [AVAudioSessionOptions.duckOthers] + /// (the option [AVAudioSessionOptions.mixWithOthers] is set implicitly). + /// Note that this forces the category to be + /// [AVAudioSessionCategory.playAndRecord], and thus is forbidden when + /// [AudioContextConfig.respectSilence] is set. + duckOthers, + + /// An option that indicates whether audio from this session mixes with audio + /// from active sessions in other audio apps. + /// + /// On Android, this will set the focus [AndroidAudioFocus.none]. + /// + /// On iOS, this will set the option [AVAudioSessionOptions.mixWithOthers]. + mixWithOthers, +} diff --git a/packages/audioplayers_platform_interface/lib/src/api/audio_event.dart b/packages/audioplayers_platform_interface/lib/src/api/audio_event.dart new file mode 100644 index 000000000..fabecce50 --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/api/audio_event.dart @@ -0,0 +1,64 @@ +import 'package:flutter/foundation.dart'; + +enum AudioEventType { + log, + duration, + seekComplete, + complete, + prepared, +} + +/// Event emitted from the platform implementation. +@immutable +class AudioEvent { + /// Creates an instance of [AudioEvent]. + /// + /// The [eventType] argument is required. + const AudioEvent({ + required this.eventType, + this.duration, + this.logMessage, + this.isPrepared, + }); + + /// The type of the event. + final AudioEventType eventType; + + /// Duration of the audio. + final Duration? duration; + + /// Log message in the player scope. + final String? logMessage; + + /// Whether the source is prepared to be played. + final bool? isPrepared; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is AudioEvent && + runtimeType == other.runtimeType && + eventType == other.eventType && + duration == other.duration && + logMessage == other.logMessage && + isPrepared == other.isPrepared; + } + + @override + int get hashCode => Object.hash( + eventType, + duration, + logMessage, + isPrepared, + ); + + @override + String toString() { + return 'AudioEvent(' + 'eventType: $eventType, ' + 'duration: $duration, ' + 'logMessage: $logMessage, ' + 'isPrepared: $isPrepared' + ')'; + } +} diff --git a/packages/audioplayers_platform_interface/lib/src/api/global_audio_event.dart b/packages/audioplayers_platform_interface/lib/src/api/global_audio_event.dart new file mode 100644 index 000000000..888fd1b2e --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/api/global_audio_event.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; + +enum GlobalAudioEventType { + log, +} + +/// Event emitted from the platform implementation. +@immutable +class GlobalAudioEvent { + /// Creates an instance of [GlobalAudioEvent]. + /// + /// The [eventType] argument is required. + const GlobalAudioEvent({ + required this.eventType, + this.logMessage, + }); + + /// The type of the event. + final GlobalAudioEventType eventType; + + /// Log message in the global scope. + final String? logMessage; + + @override + bool operator ==(Object other) { + return identical(this, other) || + other is GlobalAudioEvent && + runtimeType == other.runtimeType && + eventType == other.eventType && + logMessage == other.logMessage; + } + + @override + int get hashCode => Object.hash( + eventType, + logMessage, + ); + + @override + String toString() { + return 'GlobalAudioEvent(' + 'eventType: $eventType, ' + 'logMessage: $logMessage' + ')'; + } +} diff --git a/packages/audioplayers_platform_interface/lib/src/api/player_mode.dart b/packages/audioplayers_platform_interface/lib/src/api/player_mode.dart new file mode 100644 index 000000000..36d751ab9 --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/api/player_mode.dart @@ -0,0 +1,16 @@ +/// This represents what kind of native implementation is used by the player. +/// +/// Currently, this distinction is only relevant for Android. +enum PlayerMode { + /// Ideal for long media files or streams. + mediaPlayer, + + /// Ideal for short audio files, since it reduces the impacts on visuals or + /// UI performance. + /// + /// In this mode the backend won't fire any duration, position or playback + /// completion events. This means you are responsible for stopping the player. + /// Also, it is not possible to use the seek method to set the audio to a + /// specific position. + lowLatency, +} diff --git a/packages/audioplayers/lib/src/api/player_state.dart b/packages/audioplayers_platform_interface/lib/src/api/player_state.dart similarity index 78% rename from packages/audioplayers/lib/src/api/player_state.dart rename to packages/audioplayers_platform_interface/lib/src/api/player_state.dart index 7e77136e1..527818a95 100644 --- a/packages/audioplayers/lib/src/api/player_state.dart +++ b/packages/audioplayers_platform_interface/lib/src/api/player_state.dart @@ -11,4 +11,7 @@ enum PlayerState { /// The audio successfully completed (reached the end). completed, + + /// The player has been disposed and should not be used anymore. + disposed, } diff --git a/packages/audioplayers/lib/src/api/release_mode.dart b/packages/audioplayers_platform_interface/lib/src/api/release_mode.dart similarity index 87% rename from packages/audioplayers/lib/src/api/release_mode.dart rename to packages/audioplayers_platform_interface/lib/src/api/release_mode.dart index 6d64bd378..a382c3a11 100644 --- a/packages/audioplayers/lib/src/api/release_mode.dart +++ b/packages/audioplayers_platform_interface/lib/src/api/release_mode.dart @@ -1,6 +1,6 @@ /// This enum is meant to be used as a parameter of setReleaseMode method. /// -/// It represents the behaviour of AudioPlayer when an audio is finished or +/// It represents the behavior of AudioPlayer when an audio is finished or /// stopped. enum ReleaseMode { /// Releases all resources, just like calling release method. @@ -10,7 +10,7 @@ enum ReleaseMode { /// it will be downloaded again). /// In iOS and macOS, works just like stop method. /// - /// This is the default behaviour. + /// This is the default behavior. release, /// Keeps buffered data and plays again after completion, creating a loop. diff --git a/packages/audioplayers_platform_interface/lib/src/audioplayers_platform.dart b/packages/audioplayers_platform_interface/lib/src/audioplayers_platform.dart new file mode 100644 index 000000000..4fab6ca48 --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/audioplayers_platform.dart @@ -0,0 +1,289 @@ +import 'dart:async'; + +import 'package:audioplayers_platform_interface/src/api/audio_context.dart'; +import 'package:audioplayers_platform_interface/src/api/audio_event.dart'; +import 'package:audioplayers_platform_interface/src/api/player_mode.dart'; +import 'package:audioplayers_platform_interface/src/api/release_mode.dart'; +import 'package:audioplayers_platform_interface/src/audioplayers_platform_interface.dart'; +import 'package:audioplayers_platform_interface/src/map_extension.dart'; +import 'package:audioplayers_platform_interface/src/method_channel_extension.dart'; +import 'package:flutter/services.dart'; + +class AudioplayersPlatform extends AudioplayersPlatformInterface + with MethodChannelAudioplayersPlatform, EventChannelAudioplayersPlatform { + AudioplayersPlatform(); + + @override + Future create(String playerId) async { + await super.create(playerId); + createEventStream(playerId); + } + + @override + Future dispose(String playerId) async { + await super.dispose(playerId); + disposeEventStream(playerId); + } +} + +mixin MethodChannelAudioplayersPlatform + implements MethodChannelAudioplayersPlatformInterface { + static const MethodChannel _methodChannel = + MethodChannel('xyz.luan/audioplayers'); + + @override + Future create(String playerId) { + return _call('create', playerId); + } + + @override + Future dispose(String playerId) { + return _call('dispose', playerId); + } + + @override + Future getCurrentPosition(String playerId) { + return _compute('getCurrentPosition', playerId); + } + + @override + Future getDuration(String playerId) { + return _compute('getDuration', playerId); + } + + @override + Future pause(String playerId) { + return _call('pause', playerId); + } + + @override + Future release(String playerId) { + return _call('release', playerId); + } + + @override + Future resume(String playerId) { + return _call('resume', playerId); + } + + @override + Future seek(String playerId, Duration position) { + return _call( + 'seek', + playerId, + { + 'position': position.inMilliseconds, + }, + ); + } + + @override + Future setAudioContext( + String playerId, + AudioContext context, + ) { + return _call( + 'setAudioContext', + playerId, + context.toJson(), + ); + } + + @override + Future setBalance( + String playerId, + double balance, + ) { + return _call( + 'setBalance', + playerId, + {'balance': balance}, + ); + } + + @override + Future setPlayerMode( + String playerId, + PlayerMode playerMode, + ) { + return _call( + 'setPlayerMode', + playerId, + { + 'playerMode': playerMode.toString(), + }, + ); + } + + @override + Future setPlaybackRate(String playerId, double playbackRate) { + return _call( + 'setPlaybackRate', + playerId, + {'playbackRate': playbackRate}, + ); + } + + @override + Future setReleaseMode(String playerId, ReleaseMode releaseMode) { + return _call( + 'setReleaseMode', + playerId, + { + 'releaseMode': releaseMode.toString(), + }, + ); + } + + @override + Future setSourceBytes( + String playerId, + Uint8List bytes, { + String? mimeType, + }) { + return _call( + 'setSourceBytes', + playerId, + { + 'bytes': bytes, + 'mimeType': mimeType, + }, + ); + } + + @override + Future setSourceUrl( + String playerId, + String url, { + bool? isLocal, + String? mimeType, + }) { + return _call( + 'setSourceUrl', + playerId, + { + 'url': url, + 'isLocal': isLocal, + 'mimeType': mimeType, + }, + ); + } + + @override + Future setVolume(String playerId, double volume) { + return _call( + 'setVolume', + playerId, + { + 'volume': volume, + }, + ); + } + + @override + Future stop(String playerId) { + return _call('stop', playerId); + } + + @override + Future emitLog(String playerId, String message) { + return _call( + 'emitLog', + playerId, + { + 'message': message, + }, + ); + } + + @override + Future emitError(String playerId, String code, String message) { + return _call( + 'emitError', + playerId, + { + 'code': code, + 'message': message, + }, + ); + } + + Future _call( + String method, + String playerId, [ + Map arguments = const {}, + ]) async { + final enhancedArgs = { + 'playerId': playerId, + ...arguments, + }; + return _methodChannel.call(method, enhancedArgs); + } + + Future _compute( + String method, + String playerId, [ + Map arguments = const {}, + ]) async { + final enhancedArgs = { + 'playerId': playerId, + ...arguments, + }; + return _methodChannel.compute(method, enhancedArgs); + } +} + +mixin EventChannelAudioplayersPlatform + implements EventChannelAudioplayersPlatformInterface { + final Map> streams = {}; + + // Only can be used after have created the event channel on the native side. + void createEventStream(String playerId) { + final eventChannel = EventChannel('xyz.luan/audioplayers/events/$playerId'); + streams[playerId] = eventChannel.receiveBroadcastStream().map( + (dynamic event) { + final map = event as Map; + final eventType = map.getString('event'); + switch (eventType) { + case 'audio.onDuration': + final millis = map.getInt('value'); + return AudioEvent( + eventType: AudioEventType.duration, + duration: millis != null + ? Duration(milliseconds: millis) + : Duration.zero, + ); + case 'audio.onComplete': + return const AudioEvent(eventType: AudioEventType.complete); + case 'audio.onSeekComplete': + return const AudioEvent(eventType: AudioEventType.seekComplete); + case 'audio.onPrepared': + final isPrepared = map.getBool('value'); + return AudioEvent( + eventType: AudioEventType.prepared, + isPrepared: isPrepared, + ); + case 'audio.onLog': + final value = map.getString('value'); + return AudioEvent( + eventType: AudioEventType.log, + logMessage: value, + ); + default: + throw UnimplementedError('Event Method does not exist $eventType'); + } + }, + ); + } + + void disposeEventStream(String playerId) { + if (streams.containsKey(playerId)) { + streams.remove(playerId); + } + } + + @override + Stream getEventStream(String playerId) { + return streams[playerId]!; + } +} diff --git a/packages/audioplayers_platform_interface/lib/src/audioplayers_platform_interface.dart b/packages/audioplayers_platform_interface/lib/src/audioplayers_platform_interface.dart new file mode 100644 index 000000000..9e3ee4d5f --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/audioplayers_platform_interface.dart @@ -0,0 +1,146 @@ +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:audioplayers_platform_interface/src/api/audio_context.dart'; +import 'package:audioplayers_platform_interface/src/api/audio_event.dart'; +import 'package:audioplayers_platform_interface/src/api/player_mode.dart'; +import 'package:audioplayers_platform_interface/src/api/release_mode.dart'; +import 'package:audioplayers_platform_interface/src/audioplayers_platform.dart'; +import 'package:meta/meta.dart'; +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +/// The interface that implementations of audioplayers must implement. +/// +/// Platform implementations should extend this class rather than implement it +/// as `audioplayers` does not consider newly added methods to be breaking +/// changes. Extending this class (using `extends`) ensures that the subclass +/// will get the default implementation, while platform implementations that +/// `implements` this interface will be broken by newly added +/// [AudioplayersPlatformInterface] methods. +abstract class AudioplayersPlatformInterface extends PlatformInterface + implements + MethodChannelAudioplayersPlatformInterface, + EventChannelAudioplayersPlatformInterface { + AudioplayersPlatformInterface() : super(token: _token); + + static final Object _token = Object(); + + /// The default instance of [AudioplayersPlatformInterface] to use. + /// + /// Defaults to [AudioplayersPlatform]. + /// Platform-specific plugins should set this with their own platform-specific + /// class that extends [AudioplayersPlatformInterface] when they register + /// themselves. + static AudioplayersPlatformInterface instance = AudioplayersPlatform(); +} + +abstract class MethodChannelAudioplayersPlatformInterface { + /// Create a player instance for the given playerId. + Future create(String playerId); + + /// Dispose the player instance with the given playerId. + Future dispose(String playerId); + + /// Pauses the audio that is currently playing. + /// + /// If you call [resume] later, the audio will resume from the point that it + /// has been paused. + Future pause(String playerId); + + /// Stops the audio that is currently playing. + /// + /// The position is going to be reset and you will no longer be able to resume + /// from the last point. + Future stop(String playerId); + + /// Resumes the audio that has been paused or stopped. + Future resume(String playerId); + + /// Releases the resources associated with this media player. + /// + /// The resources are going to be fetched or buffered again as needed. + Future release(String playerId); + + /// Moves the cursor to the desired position. + Future seek(String playerId, Duration position); + + /// Sets the stereo balance. + /// + /// -1 - The left channel is at full volume; the right channel is silent. + /// 1 - The right channel is at full volume; the left channel is silent. + /// 0 - Both channels are at the same volume. + Future setBalance(String playerId, double balance); + + /// Sets the volume (amplitude). + /// + /// 0 is mute and 1 is the max volume. The values between 0 and 1 are linearly + /// interpolated. + Future setVolume(String playerId, double volume); + + /// Sets the release mode. + /// + /// Check [ReleaseMode]'s doc to understand the difference between the modes. + Future setReleaseMode(String playerId, ReleaseMode releaseMode); + + /// Sets the playback rate. + /// + /// iOS and macOS have limits between 0.5 and 2x + /// Android SDK version should be 23 or higher + Future setPlaybackRate(String playerId, double playbackRate); + + /// Configures the player to read the audio from a URL. + /// + /// The resources will start being fetched or buffered as soon as you call + /// this method. + Future setSourceUrl( + String playerId, + String url, { + bool? isLocal, + String? mimeType, + }); + + /// Configures the play to read the audio from a byte array. + Future setSourceBytes( + String playerId, + Uint8List bytes, { + String? mimeType, + }); + + Future setAudioContext( + String playerId, + AudioContext audioContext, + ); + + Future setPlayerMode( + String playerId, + PlayerMode playerMode, + ); + + /// Returns the duration of the media, in milliseconds, if available. + /// + /// Might not be available if: + /// * source has not been set or prepared yet (for remote audios it must be + /// downloaded and buffered first) + /// * source does not support operation (e.g. streams) + /// * otherwise not supported (e.g. LOW_LATENCY mode on Android) + Future getDuration(String playerId); + + /// Returns the current position of playback, in milliseconds, if available. + /// + /// Might not be available if: + /// * source has not been set or prepared yet (for remote audios it must be + /// downloaded and buffered first) + /// * source does not support operation (e.g. streams) + /// * otherwise not supported (e.g. LOW_LATENCY mode on Android) + Future getCurrentPosition(String playerId); + + @visibleForTesting + Future emitLog(String playerId, String message); + + @visibleForTesting + Future emitError(String playerId, String code, String message); +} + +abstract class EventChannelAudioplayersPlatformInterface { + Stream getEventStream(String playerId); +} diff --git a/packages/audioplayers_platform_interface/lib/src/global_audioplayers_platform.dart b/packages/audioplayers_platform_interface/lib/src/global_audioplayers_platform.dart new file mode 100644 index 000000000..f5c8db736 --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/global_audioplayers_platform.dart @@ -0,0 +1,79 @@ +import 'package:audioplayers_platform_interface/src/api/audio_context.dart'; +import 'package:audioplayers_platform_interface/src/api/global_audio_event.dart'; +import 'package:audioplayers_platform_interface/src/global_audioplayers_platform_interface.dart'; +import 'package:audioplayers_platform_interface/src/map_extension.dart'; +import 'package:audioplayers_platform_interface/src/method_channel_extension.dart'; +import 'package:flutter/services.dart'; + +class GlobalAudioplayersPlatform extends GlobalAudioplayersPlatformInterface + with + MethodChannelGlobalAudioplayersPlatform, + EventChannelGlobalAudioplayersPlatform { + GlobalAudioplayersPlatform(); +} + +mixin MethodChannelGlobalAudioplayersPlatform + implements MethodChannelGlobalAudioplayersPlatformInterface { + static const MethodChannel _globalMethodChannel = + MethodChannel('xyz.luan/audioplayers.global'); + + @override + Future init() { + return _globalMethodChannel.call('init'); + } + + @override + Future setGlobalAudioContext(AudioContext ctx) { + return _globalMethodChannel.call( + 'setAudioContext', + ctx.toJson(), + ); + } + + @override + Future emitGlobalLog(String message) { + return _globalMethodChannel.call( + 'emitLog', + { + 'message': message, + }, + ); + } + + @override + Future emitGlobalError(String code, String message) { + return _globalMethodChannel.call( + 'emitError', + { + 'code': code, + 'message': message, + }, + ); + } +} + +mixin EventChannelGlobalAudioplayersPlatform + implements EventChannelGlobalAudioplayersPlatformInterface { + static const _globalEventChannel = + EventChannel('xyz.luan/audioplayers.global/events'); + + @override + Stream getGlobalEventStream() { + return _globalEventChannel.receiveBroadcastStream().map((dynamic event) { + final map = event as Map; + final eventType = map.getString('event'); + switch (eventType) { + case 'audio.onLog': + final value = map.getString('value'); + return GlobalAudioEvent( + eventType: GlobalAudioEventType.log, + logMessage: value, + ); + default: + throw UnimplementedError( + 'Global Event Method does not exist $eventType', + ); + } + }); + } +} diff --git a/packages/audioplayers_platform_interface/lib/src/global_audioplayers_platform_interface.dart b/packages/audioplayers_platform_interface/lib/src/global_audioplayers_platform_interface.dart new file mode 100644 index 000000000..058422440 --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/global_audioplayers_platform_interface.dart @@ -0,0 +1,32 @@ +import 'package:audioplayers_platform_interface/src/api/audio_context.dart'; +import 'package:audioplayers_platform_interface/src/api/global_audio_event.dart'; +import 'package:audioplayers_platform_interface/src/global_audioplayers_platform.dart'; +import 'package:meta/meta.dart'; + +abstract class GlobalAudioplayersPlatformInterface + implements + MethodChannelGlobalAudioplayersPlatformInterface, + EventChannelGlobalAudioplayersPlatformInterface { + static GlobalAudioplayersPlatformInterface instance = + GlobalAudioplayersPlatform(); +} + +abstract class MethodChannelGlobalAudioplayersPlatformInterface { + /// Initializes the platform interface and disposes all existing players. + /// + /// This method is called when the plugin is first initialized + /// and on every full restart. + Future init(); + + Future setGlobalAudioContext(AudioContext ctx); + + @visibleForTesting + Future emitGlobalLog(String message); + + @visibleForTesting + Future emitGlobalError(String code, String message); +} + +abstract class EventChannelGlobalAudioplayersPlatformInterface { + Stream getGlobalEventStream(); +} diff --git a/packages/audioplayers_platform_interface/lib/src/map_extension.dart b/packages/audioplayers_platform_interface/lib/src/map_extension.dart new file mode 100644 index 000000000..c01f66cb8 --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/map_extension.dart @@ -0,0 +1,15 @@ +extension MapParser on Map { + bool containsKey(String key) => this.containsKey(key); + + String? getString(String key) { + return this[key] as String?; + } + + int? getInt(String key) { + return this[key] as int?; + } + + bool? getBool(String key) { + return this[key] as bool?; + } +} diff --git a/packages/audioplayers_platform_interface/lib/src/method_channel_extension.dart b/packages/audioplayers_platform_interface/lib/src/method_channel_extension.dart new file mode 100644 index 000000000..377c86ddc --- /dev/null +++ b/packages/audioplayers_platform_interface/lib/src/method_channel_extension.dart @@ -0,0 +1,14 @@ +import 'package:flutter/services.dart'; + +extension StandardMethodChannel on MethodChannel { + Future call( + String method, [ + Map args = const {}, + ]) async { + return invokeMethod(method, args); + } + + Future compute(String method, Map args) async { + return invokeMethod(method, args); + } +} diff --git a/packages/audioplayers_platform_interface/pubspec.yaml b/packages/audioplayers_platform_interface/pubspec.yaml new file mode 100644 index 000000000..7d7590438 --- /dev/null +++ b/packages/audioplayers_platform_interface/pubspec.yaml @@ -0,0 +1,22 @@ +name: audioplayers_platform_interface +resolution: workspace +description: The platform interface for audioplayers, a Flutter plugin to play multiple audio files simultaneously +version: 7.1.1 +homepage: https://github.com/bluefireteam/audioplayers +repository: https://github.com/bluefireteam/audioplayers/tree/master/packages/audioplayers_platform_interface + +dependencies: + collection: ^1.17.1 + flutter: + sdk: flutter + meta: ^1.7.0 + plugin_platform_interface: ^2.0.2 + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + +environment: + sdk: ^3.6.0 + flutter: '>=3.27.0' diff --git a/packages/audioplayers_platform_interface/test/audio_context_test.dart b/packages/audioplayers_platform_interface/test/audio_context_test.dart new file mode 100644 index 000000000..f156d8634 --- /dev/null +++ b/packages/audioplayers_platform_interface/test/audio_context_test.dart @@ -0,0 +1,148 @@ +//ignore_for_file: avoid_redundant_argument_values + +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:audioplayers_platform_interface/src/api/audio_context_config.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('Create default AudioContext', () async { + final context = AudioContext(); + expect( + context, + AudioContext( + android: const AudioContextAndroid( + isSpeakerphoneOn: false, + audioMode: AndroidAudioMode.normal, + stayAwake: false, + contentType: AndroidContentType.music, + usageType: AndroidUsageType.media, + audioFocus: AndroidAudioFocus.gain, + ), + iOS: AudioContextIOS( + category: AVAudioSessionCategory.playback, + options: const {}, + ), + ), + ); + }); + + test('Check AudioContextConfig assertions', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + const boolValues = {true, false}; + const focusValues = AudioContextConfigFocus.values; + const routeValues = AudioContextConfigRoute.values; + + final throwsAssertion = []; + for (final focus in focusValues) { + for (final isRespectSilence in boolValues) { + for (final isStayAwake in boolValues) { + for (final route in routeValues) { + final config = AudioContextConfig( + focus: focus, + respectSilence: isRespectSilence, + stayAwake: isStayAwake, + route: route, + ); + try { + config.build(); + throwsAssertion.add(false); + } on AssertionError catch (e) { + if (e.message + .toString() + .startsWith('Invalid AudioContextConfig')) { + throwsAssertion.add(true); + } else { + fail( + 'Assertion of $config does not match the expected ' + 'description. See: $e', + ); + } + } + } + } + } + } + + // Ensure assertions keep thrown on the correct cases. + expect( + throwsAssertion, + const [ + false, + false, + true, + false, + false, + true, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + false, + false, + false, + false, + false, + ], + ); + }); + + test('Create invalid AudioContextIOS', () async { + try { + // Throws AssertionError: + AudioContextIOS( + category: AVAudioSessionCategory.ambient, + options: const {AVAudioSessionOptions.mixWithOthers}, + ); + fail('AssertionError not thrown'); + // ignore: avoid_catches_without_on_clauses + } catch (e) { + expect(e, isInstanceOf()); + expect( + (e as AssertionError).message, + 'You can set the option `mixWithOthers` explicitly only if the audio ' + 'session category is `playAndRecord`, `playback`, or `multiRoute`.'); + } + }); + + test('Equality of AudioContextIOS', () async { + final context1 = AudioContextIOS( + category: AVAudioSessionCategory.playAndRecord, + options: const { + AVAudioSessionOptions.mixWithOthers, + AVAudioSessionOptions.defaultToSpeaker, + }, + ); + final context2 = AudioContextIOS( + category: AVAudioSessionCategory.playAndRecord, + options: const { + AVAudioSessionOptions.defaultToSpeaker, + AVAudioSessionOptions.mixWithOthers, + }, + ); + expect(context1, context2); + }); +} diff --git a/packages/audioplayers_platform_interface/test/audioplayers_platform_test.dart b/packages/audioplayers_platform_interface/test/audioplayers_platform_test.dart new file mode 100644 index 000000000..052d73b37 --- /dev/null +++ b/packages/audioplayers_platform_interface/test/audioplayers_platform_test.dart @@ -0,0 +1,155 @@ +import 'dart:async'; + +import 'package:audioplayers_platform_interface/src/api/audio_event.dart'; +import 'package:audioplayers_platform_interface/src/audioplayers_platform_interface.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'util.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final platform = AudioplayersPlatformInterface.instance; + + final methodCalls = []; + + void clear() { + methodCalls.clear(); + } + + MethodCall popCall() { + return methodCalls.removeAt(0); + } + + MethodCall popLastCall() { + expect(methodCalls, hasLength(1)); + return popCall(); + } + + group('AudioPlayers Method Channel', () { + setUp(() { + clear(); + + createNativeMethodHandler( + channel: 'xyz.luan/audioplayers', + handler: (MethodCall methodCall) async { + methodCalls.add(methodCall); + switch (methodCall.method) { + case 'getDuration': + return 0; + case 'getCurrentPosition': + return 0; + default: + return null; + } + }, + ); + }); + + test('#setSource', () async { + await platform.setSourceUrl( + 'p1', + 'internet.com/file.mp3', + mimeType: 'audio/wav', + ); + final call = popLastCall(); + expect(call.method, 'setSourceUrl'); + expect(call.args, { + 'playerId': 'p1', + 'url': 'internet.com/file.mp3', + 'isLocal': null, + 'mimeType': 'audio/wav', + }); + }); + + test('#resume', () async { + await platform.resume('p1'); + final call = popLastCall(); + expect(call.method, 'resume'); + expect(call.args, {'playerId': 'p1'}); + }); + + test('#pause', () async { + await platform.pause('p1'); + final call = popLastCall(); + expect(call.method, 'pause'); + expect(call.args, {'playerId': 'p1'}); + }); + + test('#getDuration', () async { + final duration = await platform.getDuration('p1'); + final call = popLastCall(); + expect(call.method, 'getDuration'); + expect(call.args, {'playerId': 'p1'}); + expect(duration, 0); + }); + + test('#getCurrentPosition', () async { + final position = await platform.getCurrentPosition('p1'); + final call = popLastCall(); + expect(call.method, 'getCurrentPosition'); + expect(call.args, {'playerId': 'p1'}); + expect(position, 0); + }); + }); + + group('AudioPlayers Event Channel', () { + test('emit events', () async { + final eventController = StreamController.broadcast(); + const playerId = 'p1'; + + createNativeEventStream( + channel: 'xyz.luan/audioplayers/events/$playerId', + byteDataStream: eventController.stream, + ); + + await platform.create(playerId); + + expect( + platform.getEventStream(playerId), + emitsInOrder([ + const AudioEvent( + eventType: AudioEventType.duration, + duration: Duration(milliseconds: 98765), + ), + const AudioEvent( + eventType: AudioEventType.log, + logMessage: 'someLogMessage', + ), + const AudioEvent( + eventType: AudioEventType.complete, + ), + const AudioEvent( + eventType: AudioEventType.seekComplete, + ), + ]), + ); + + final byteDataList = >[ + { + 'event': 'audio.onDuration', + 'value': 98765, + }, + { + 'event': 'audio.onLog', + 'value': 'someLogMessage', + }, + { + 'event': 'audio.onComplete', + }, + { + 'event': 'audio.onSeekComplete', + }, + ]; + for (final byteData in byteDataList) { + eventController.add( + const StandardMethodCodec().encodeSuccessEnvelope(byteData), + ); + } + + await eventController.close(); + await platform.dispose(playerId); + }); + }); +} diff --git a/packages/audioplayers_platform_interface/test/global_platform_test.dart b/packages/audioplayers_platform_interface/test/global_platform_test.dart new file mode 100644 index 000000000..c0c588af8 --- /dev/null +++ b/packages/audioplayers_platform_interface/test/global_platform_test.dart @@ -0,0 +1,126 @@ +import 'dart:async'; + +import 'package:audioplayers_platform_interface/src/api/audio_context.dart'; +import 'package:audioplayers_platform_interface/src/api/global_audio_event.dart'; +import 'package:audioplayers_platform_interface/src/global_audioplayers_platform_interface.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'util.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + final platform = GlobalAudioplayersPlatformInterface.instance; + + final methodCalls = []; + + void clear() { + methodCalls.clear(); + } + + MethodCall popCall() { + return methodCalls.removeAt(0); + } + + MethodCall popLastCall() { + expect(methodCalls, hasLength(1)); + return popCall(); + } + + group('Global Method Channel', () { + setUp(() { + clear(); + createNativeMethodHandler( + channel: 'xyz.luan/audioplayers.global', + handler: (MethodCall methodCall) async { + methodCalls.add(methodCall); + return null; + }, + ); + }); + + test('set AudioContext for Windows', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.windows; + await platform.setGlobalAudioContext(AudioContext()); + final call = popLastCall(); + expect(call.method, 'setAudioContext'); + expect(call.args, {}); + }); + + test('set AudioContext for macOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.macOS; + await platform.setGlobalAudioContext(AudioContext()); + final call = popLastCall(); + expect(call.method, 'setAudioContext'); + expect(call.args, {}); + }); + + test('set AudioContext for Linux', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.linux; + await platform.setGlobalAudioContext(AudioContext()); + final call = popLastCall(); + expect(call.method, 'setAudioContext'); + expect(call.args, {}); + }); + + test('set AudioContext for Android', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.android; + await platform.setGlobalAudioContext(AudioContext()); + final call = popLastCall(); + expect(call.method, 'setAudioContext'); + expect(call.args, { + 'isSpeakerphoneOn': false, + 'audioMode': 0, + 'stayAwake': false, + 'contentType': 2, + 'usageType': 1, + 'audioFocus': 1, + }); + }); + + test('set AudioContext for iOS', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + await platform.setGlobalAudioContext(AudioContext()); + final call = popLastCall(); + expect(call.method, 'setAudioContext'); + expect(call.args, {'category': 'playback', 'options': []}); + }); + }); + + group('Global Event Channel', () { + test('emit global events', () async { + final eventController = StreamController.broadcast(); + + createNativeEventStream( + channel: 'xyz.luan/audioplayers.global/events', + byteDataStream: eventController.stream, + ); + + expect( + platform.getGlobalEventStream(), + emitsInOrder([ + const GlobalAudioEvent( + eventType: GlobalAudioEventType.log, + logMessage: 'someLogMessage', + ), + ]), + ); + + final byteDataList = >[ + { + 'event': 'audio.onLog', + 'value': 'someLogMessage', + }, + ]; + for (final byteData in byteDataList) { + eventController.add( + const StandardMethodCodec().encodeSuccessEnvelope(byteData), + ); + } + + await eventController.close(); + }); + }); +} diff --git a/packages/audioplayers_platform_interface/test/util.dart b/packages/audioplayers_platform_interface/test/util.dart new file mode 100644 index 000000000..f3ac1ceb3 --- /dev/null +++ b/packages/audioplayers_platform_interface/test/util.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +extension MethodCallParser on MethodCall { + Map get args => arguments as Map; +} + +void createNativeMethodHandler({ + required String channel, + Future? Function(MethodCall message)? handler, +}) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + MethodChannel(channel), + handler, + ); +} + +// See: https://github.com/flutter/packages/blob/12609a2abbb0a30b9d32af7b73599bfc834e609e/packages/video_player/video_player_android/test/android_video_player_test.dart#L270 +void createNativeEventStream({ + required String channel, + Stream? byteDataStream, +}) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMessageHandler(channel, (ByteData? message) async { + final methodCall = const StandardMethodCodec().decodeMethodCall(message); + if (methodCall.method == 'listen') { + byteDataStream?.listen((byteData) async { + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel, + byteData, + (ByteData? data) {}, + ); + }); + return const StandardMethodCodec().encodeSuccessEnvelope(null); + } else if (methodCall.method == 'cancel') { + return const StandardMethodCodec().encodeSuccessEnvelope(null); + } else { + fail('Expected listen or cancel'); + } + }); +} diff --git a/packages/audioplayers_web/.gitignore b/packages/audioplayers_web/.gitignore new file mode 100644 index 000000000..0fa6b675c --- /dev/null +++ b/packages/audioplayers_web/.gitignore @@ -0,0 +1,46 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +#.vscode/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release diff --git a/packages/audioplayers_web/.metadata b/packages/audioplayers_web/.metadata new file mode 100644 index 000000000..fd70cabc0 --- /dev/null +++ b/packages/audioplayers_web/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: 77d935af4db863f6abd0b9c31c7e6df2a13de57b + channel: stable + +project_type: app diff --git a/packages/audioplayers_web/CHANGELOG.md b/packages/audioplayers_web/CHANGELOG.md new file mode 100644 index 000000000..a11924761 --- /dev/null +++ b/packages/audioplayers_web/CHANGELOG.md @@ -0,0 +1,111 @@ +## 5.1.1 + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +## 5.1.0 + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +## 5.0.2 + + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +## 5.0.1 + + - **DEPS**: Consider web:1.0.0 ([#1828](https://github.com/bluefireteam/audioplayers/pull/1828)). ([9d25e78d](https://github.com/bluefireteam/audioplayers/commit/9d25e78d24a687c90ffa76f034c418d2bbe45251)) + +## 5.0.0 + +> Note: This release has breaking changes. + + - **FEAT**: Support byte array and data URIs via mimeType ([#1763](https://github.com/bluefireteam/audioplayers/issues/1763)). ([eaf7ce86](https://github.com/bluefireteam/audioplayers/commit/eaf7ce86ad271097365fcf9e3a03fc341629ae47)) + - **FEAT**(web): Support compilation to Wasm ([#1766](https://github.com/bluefireteam/audioplayers/issues/1766)). ([1b1a0cf9](https://github.com/bluefireteam/audioplayers/commit/1b1a0cf92e950bc520598426d3f073c3bd5a6a28)) + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + +## 4.1.0 + + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FEAT**: Release source for Web, Linux, Windows ([#1517](https://github.com/bluefireteam/audioplayers/issues/1517)). ([09496dcb](https://github.com/bluefireteam/audioplayers/commit/09496dcbf478af330e37be833184439b43b5ac44)) + +## 4.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +## 3.1.0 + + - **REFACTOR**: Adapt to flame_lint v0.2.0+2 ([#1477](https://github.com/bluefireteam/audioplayers/issues/1477)). ([e1d7fb6a](https://github.com/bluefireteam/audioplayers/commit/e1d7fb6ab57c8a523c80dfc673bde3b7379b2add)) + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: `AudioElement` is not getting released correctly ([#1516](https://github.com/bluefireteam/audioplayers/issues/1516)). ([32210f34](https://github.com/bluefireteam/audioplayers/commit/32210f34b186b44cc9c0484d7f67641162b325f6)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + - **FIX**(web): Avoid stutter when starting playback ([#1476](https://github.com/bluefireteam/audioplayers/issues/1476)). ([a28eed02](https://github.com/bluefireteam/audioplayers/commit/a28eed02f4e67e372d2b8f7c5bb271ffe6e09ec8)) + - **FEAT**: Adapt position update interval of darwin, linux, web ([#1492](https://github.com/bluefireteam/audioplayers/issues/1492)). ([ab5bdf6a](https://github.com/bluefireteam/audioplayers/commit/ab5bdf6a2bcbf7e984d4d897e43a67b3684c52d8)) + +## 3.0.1 + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +## 3.0.0 + +> Note: This release has breaking changes. + + - **FIX**(web): make start and resume async ([#1436](https://github.com/bluefireteam/audioplayers/issues/1436)). ([b95bc8fa](https://github.com/bluefireteam/audioplayers/commit/b95bc8fa176e0d28a4d3d5ba6d26cafe699f1540)) + - **FEAT**: extract AudioContext from audio_context_config ([#1440](https://github.com/bluefireteam/audioplayers/issues/1440)). ([e59c3b9f](https://github.com/bluefireteam/audioplayers/commit/e59c3b9f07c1a72f9bf4e424fa3b011645f191d2)) + - **FEAT**(web): make setUrl async, make properties of `WrappedPlayer` private ([#1439](https://github.com/bluefireteam/audioplayers/issues/1439)). ([a051c335](https://github.com/bluefireteam/audioplayers/commit/a051c335a6cc0d1f6314f3f0c9f637920c3d6360)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **BREAKING** **REFACTOR**: prevent from confusing and conflicting class names ([#1465](https://github.com/bluefireteam/audioplayers/issues/1465)). ([7cdb8586](https://github.com/bluefireteam/audioplayers/commit/7cdb858605f24f0abd1a225e04922830233f3e96)) + - **BREAKING** **REFACTOR**: improve separation of global audioplayer interface ([#1443](https://github.com/bluefireteam/audioplayers/issues/1443)). ([c0b3f85c](https://github.com/bluefireteam/audioplayers/commit/c0b3f85c477f0313299cc2a2898840d6c7d8dcd9)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + - **BREAKING** **FEAT**: expose classes of package `audioplayers_platform_interface` ([#1442](https://github.com/bluefireteam/audioplayers/issues/1442)). ([a6f89be1](https://github.com/bluefireteam/audioplayers/commit/a6f89be181b7bd664eaf96cb9509bbc5adf5dbb9)) + +### Migration instructions + +| Before | After | +|---|---| +| `AudioplayersPlugin` | `AudioplayersPlugin`, `WebAudioplayersPlatform` and `WebGlobalAudioplayersPlatform` | + +## 2.2.0 + + - **FIX**: use external factory for classes tagged with "@staticInterop" ([#1379](https://github.com/bluefireteam/audioplayers/issues/1379)). ([21d70504](https://github.com/bluefireteam/audioplayers/commit/21d7050455351b0c4ead9a3e2efbc8857115f247)) + +## 2.1.1 + + - Update a dependency to the latest release. + +## 2.1.0 + + - **FIX**: handle infinite value on getDuration for live streams ([#1287](https://github.com/bluefireteam/audioplayers/issues/1287)). ([15f2c78f](https://github.com/bluefireteam/audioplayers/commit/15f2c78f79a68349fe33ac1a26ffc67cfaaf1211)) + - **FEAT**: add setBalance ([#58](https://github.com/bluefireteam/audioplayers/issues/58)) ([#1282](https://github.com/bluefireteam/audioplayers/issues/1282)). ([782fc9df](https://github.com/bluefireteam/audioplayers/commit/782fc9dff24a2ab9681496fd7c4c8fed451eac35)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + +## 2.0.1 + + - **FIX**: handle infinite value on getDuration for live streams ([#1287](https://github.com/bluefireteam/audioplayers/issues/1287)). ([15f2c78f](https://github.com/bluefireteam/audioplayers/commit/15f2c78f79a68349fe33ac1a26ffc67cfaaf1211)) + +## 2.0.0 + +> Note: This release has breaking changes. + + - **FIX**: bugs from integration tests (#1268). ([d849c67f](https://github.com/bluefireteam/audioplayers/commit/d849c67f6916fb3800998d7d3f1c2752a5b9b9e7)) + - **FIX**: reset position, when stop or playing ended (#1246). ([d56f40fb](https://github.com/bluefireteam/audioplayers/commit/d56f40fbe89d2a5399f8cd0041b15150d6f72e01)) + - **FIX**: handle infinite duration (#1192). ([1d1600ba](https://github.com/bluefireteam/audioplayers/commit/1d1600bae372b1e07bd12966cd36571b6809d96a)) + - **BREAKING** **REFACTOR**: remove unused playerStateStream (#1280). ([27f9de22](https://github.com/bluefireteam/audioplayers/commit/27f9de224c7bc1f948356e917bf8b9c411fe9742)) + +## 1.0.0 + + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +## 1.0.0-rc.3 + + - **FEAT**: Add onPlayerCompletion, onPlayerStateChanged and onDurationChanged for web (#1123). ([760e0c94](https://github.com/bluefireteam/audioplayers/commit/760e0c9443f4c63aadf4c5498767aeac6cd79346)) + +## 1.0.0-rc.2 + +## 1.0.0-rc.1 + + - First release after federation + diff --git a/packages/audioplayers_web/LICENSE b/packages/audioplayers_web/LICENSE new file mode 100644 index 000000000..1a581b05c --- /dev/null +++ b/packages/audioplayers_web/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audioplayers_web/README.md b/packages/audioplayers_web/README.md new file mode 100644 index 000000000..4d1ec5c96 --- /dev/null +++ b/packages/audioplayers_web/README.md @@ -0,0 +1,23 @@ +

+ + AudioPlayers + +

+ +--- + +# audioplayers_web +

+ + + + +

+ +The web implementation of [`audioplayers`](https://pub.dev/packages/audioplayers). + +## Usage + +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can simply use `audioplayers` normally. +This package will be automatically included in your app when you do, so you do not need to add it to your `pubspec.yaml`. diff --git a/packages/audioplayers_web/analysis_options.yaml b/packages/audioplayers_web/analysis_options.yaml new file mode 100644 index 000000000..85732fa02 --- /dev/null +++ b/packages/audioplayers_web/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/audioplayers_web/lib/audioplayers_web.dart b/packages/audioplayers_web/lib/audioplayers_web.dart new file mode 100644 index 000000000..b26d5d2a5 --- /dev/null +++ b/packages/audioplayers_web/lib/audioplayers_web.dart @@ -0,0 +1,166 @@ +import 'dart:async'; + +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:audioplayers_web/global_audioplayers_web.dart'; +import 'package:audioplayers_web/num_extension.dart'; +import 'package:audioplayers_web/wrapped_player.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; + +class AudioplayersPlugin { + /// The entrypoint called by the generated plugin registrant. + static void registerWith(Registrar registrar) { + AudioplayersPlatformInterface.instance = WebAudioplayersPlatform(); + GlobalAudioplayersPlatformInterface.instance = + WebGlobalAudioplayersPlatform(); + } +} + +class WebAudioplayersPlatform extends AudioplayersPlatformInterface { + // players by playerId + Map players = {}; + + @override + Future create(String playerId) async { + players[playerId] = WrappedPlayer(playerId); + } + + WrappedPlayer getPlayer(String playerId) { + return players[playerId] != null + ? players[playerId]! + : throw PlatformException( + code: 'WebAudioError', + message: + 'Player has not yet been created or has already been disposed.', + ); + } + + @override + Future getCurrentPosition(String playerId) async { + final position = getPlayer(playerId).player?.currentTime; + if (position == null) { + return null; + } + return (position * 1000).toInt(); + } + + @override + Future getDuration(String playerId) async { + final jsDuration = getPlayer(playerId).player?.duration; + if (jsDuration == null) { + return null; + } + return jsDuration.fromSecondsToDuration().inMilliseconds; + } + + @override + Future pause(String playerId) async { + getPlayer(playerId).pause(); + } + + @override + Future release(String playerId) async { + getPlayer(playerId).release(); + } + + @override + Future resume(String playerId) async { + await getPlayer(playerId).resume(); + } + + @override + Future seek(String playerId, Duration position) async { + getPlayer(playerId).seek(position.inMilliseconds); + } + + @override + Future setAudioContext( + String playerId, + AudioContext audioContext, + ) async { + getPlayer(playerId).eventStreamController.add( + const AudioEvent( + eventType: AudioEventType.log, + logMessage: 'Setting AudioContext is not supported on Web', + ), + ); + } + + @override + Future setPlayerMode( + String playerId, + PlayerMode playerMode, + ) async { + // no-op: web doesn't have multiple modes + } + + @override + Future setPlaybackRate(String playerId, double playbackRate) async { + getPlayer(playerId).playbackRate = playbackRate; + } + + @override + Future setReleaseMode(String playerId, ReleaseMode releaseMode) async { + getPlayer(playerId).releaseMode = releaseMode; + } + + @override + Future setSourceUrl( + String playerId, + String url, { + bool? isLocal, + String? mimeType, + }) async { + await getPlayer(playerId).setUrl(url); + } + + @override + Future setSourceBytes( + String playerId, + Uint8List bytes, { + String? mimeType, + }) async { + // Convert to data uri as workaround. + final uri = Uri.dataFromBytes(bytes, mimeType: mimeType ?? 'audio/mpeg'); + await getPlayer(playerId).setUrl(uri.toString()); + } + + @override + Future setVolume(String playerId, double volume) async { + getPlayer(playerId).volume = volume; + } + + @override + Future setBalance(String playerId, double balance) async { + getPlayer(playerId).balance = balance; + } + + @override + Future stop(String playerId) async { + getPlayer(playerId).stop(); + } + + @override + Future emitLog(String playerId, String message) async { + getPlayer(playerId).log(message); + } + + @override + Future emitError(String playerId, String code, String message) async { + getPlayer(playerId) + .eventStreamController + .addError(PlatformException(code: code, message: message)); + } + + @override + Stream getEventStream(String playerId) { + return getPlayer(playerId).eventStreamController.stream; + } + + @override + Future dispose(String playerId) async { + final player = getPlayer(playerId); + await player.dispose(); + players.remove(playerId); + } +} diff --git a/packages/audioplayers_web/lib/global_audioplayers_web.dart b/packages/audioplayers_web/lib/global_audioplayers_web.dart new file mode 100644 index 000000000..9f2a225bc --- /dev/null +++ b/packages/audioplayers_web/lib/global_audioplayers_web.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:audioplayers_web/audioplayers_web.dart'; +import 'package:flutter/services.dart'; + +class WebGlobalAudioplayersPlatform + extends GlobalAudioplayersPlatformInterface { + final _eventStreamController = StreamController.broadcast(); + + @override + Future init() async { + final instance = + AudioplayersPlatformInterface.instance as WebAudioplayersPlatform; + await Future.wait( + instance.players.values.map((player) => player.dispose()), + ); + instance.players.clear(); + } + + @override + Future setGlobalAudioContext(AudioContext ctx) async { + _eventStreamController.add( + const GlobalAudioEvent( + eventType: GlobalAudioEventType.log, + logMessage: 'Setting global AudioContext is not supported on Web', + ), + ); + } + + @override + Stream getGlobalEventStream() { + return _eventStreamController.stream; + } + + @override + Future emitGlobalLog(String message) async { + _eventStreamController.add( + GlobalAudioEvent( + eventType: GlobalAudioEventType.log, + logMessage: message, + ), + ); + } + + @override + Future emitGlobalError(String code, String message) async { + _eventStreamController + .addError(PlatformException(code: code, message: message)); + } +} diff --git a/packages/audioplayers_web/lib/num_extension.dart b/packages/audioplayers_web/lib/num_extension.dart new file mode 100644 index 000000000..9a1212f26 --- /dev/null +++ b/packages/audioplayers_web/lib/num_extension.dart @@ -0,0 +1,6 @@ +extension NumExtension on num { + /// Converts [num] (expected in seconds) to the duration. + Duration fromSecondsToDuration() => Duration( + milliseconds: ((isNaN || isInfinite ? 0 : this) * 1000).round(), + ); +} diff --git a/packages/audioplayers_web/lib/wrapped_player.dart b/packages/audioplayers_web/lib/wrapped_player.dart new file mode 100644 index 000000000..0991a874d --- /dev/null +++ b/packages/audioplayers_web/lib/wrapped_player.dart @@ -0,0 +1,245 @@ +import 'dart:async'; +import 'dart:js_interop'; + +import 'package:audioplayers_platform_interface/audioplayers_platform_interface.dart'; +import 'package:audioplayers_web/num_extension.dart'; +import 'package:flutter/services.dart'; +import 'package:web/web.dart' as web; + +class WrappedPlayer { + final String playerId; + final eventStreamController = StreamController.broadcast(); + + double? _pausedAt; + double _currentVolume = 1.0; + double _currentPlaybackRate = 1.0; + ReleaseMode _currentReleaseMode = ReleaseMode.release; + String? _currentUrl; + bool _isPlaying = false; + + web.HTMLAudioElement? player; + web.StereoPannerNode? _stereoPanner; + StreamSubscription? _playerEndedSubscription; + StreamSubscription? _playerLoadedDataSubscription; + StreamSubscription? _playerPlaySubscription; + StreamSubscription? _playerSeekedSubscription; + StreamSubscription? _playerErrorSubscription; + + WrappedPlayer(this.playerId); + + Future setUrl(String url) async { + if (_currentUrl == url) { + eventStreamController.add( + const AudioEvent( + eventType: AudioEventType.prepared, + isPrepared: true, + ), + ); + return; + } + _currentUrl = url; + + release(); + recreateNode(); + if (_isPlaying) { + await resume(); + } + } + + set volume(double volume) { + _currentVolume = volume; + player?.volume = volume; + } + + set balance(double balance) { + _stereoPanner?.pan.value = balance; + } + + set playbackRate(double rate) { + _currentPlaybackRate = rate; + player?.playbackRate = rate; + } + + void recreateNode() { + final currentUrl = _currentUrl; + if (currentUrl == null) { + return; + } + + final p = player = web.HTMLAudioElement(); + p.preload = 'auto'; + p.src = currentUrl; + // As the AudioElement is created dynamically via script, + // features like 'stereo panning' need the CORS header to be enabled. + // See: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS + p.crossOrigin = 'anonymous'; + p.loop = shouldLoop(); + p.volume = _currentVolume; + p.playbackRate = _currentPlaybackRate; + + _setupStreams(p); + + // setup stereo panning + final audioContext = web.AudioContext(); + final source = audioContext.createMediaElementSource(player!); + _stereoPanner = audioContext.createStereoPanner(); + source.connect(_stereoPanner!); + _stereoPanner?.connect(audioContext.destination); + + // Preload the source + p.load(); + } + + void _setupStreams(web.HTMLAudioElement p) { + _playerLoadedDataSubscription = p.onLoadedData.listen( + (_) { + eventStreamController.add( + const AudioEvent( + eventType: AudioEventType.prepared, + isPrepared: true, + ), + ); + eventStreamController.add( + AudioEvent( + eventType: AudioEventType.duration, + duration: p.duration.fromSecondsToDuration(), + ), + ); + }, + onError: eventStreamController.addError, + ); + _playerPlaySubscription = p.onPlay.listen( + (_) { + eventStreamController.add( + AudioEvent( + eventType: AudioEventType.duration, + duration: p.duration.fromSecondsToDuration(), + ), + ); + }, + onError: eventStreamController.addError, + ); + _playerSeekedSubscription = p.onSeeked.listen( + (_) { + eventStreamController.add( + const AudioEvent(eventType: AudioEventType.seekComplete), + ); + }, + onError: eventStreamController.addError, + ); + _playerEndedSubscription = p.onEnded.listen( + (_) { + if (_currentReleaseMode == ReleaseMode.release) { + release(); + } else { + stop(); + } + eventStreamController.add( + const AudioEvent(eventType: AudioEventType.complete), + ); + }, + onError: eventStreamController.addError, + ); + _playerErrorSubscription = p.onError.listen( + (_) { + String platformMsg; + if (p.error != null) { + platformMsg = 'Failed to set source. For troubleshooting, see ' + 'https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md'; + } else { + platformMsg = 'Unknown web error. See details.'; + } + eventStreamController.addError( + PlatformException( + code: 'WebAudioError', + message: platformMsg, + details: '${p.error?.runtimeType}: ' + '${p.error?.message} (Code: ${p.error?.code})', + ), + ); + }, + onError: eventStreamController.addError, + ); + } + + bool shouldLoop() => _currentReleaseMode == ReleaseMode.loop; + + set releaseMode(ReleaseMode releaseMode) { + _currentReleaseMode = releaseMode; + player?.loop = shouldLoop(); + } + + void release() { + pause(); + // Release `AudioElement` correctly (#966) + player?.src = ''; + player?.remove(); + player = null; + _stereoPanner = null; + + _playerLoadedDataSubscription?.cancel(); + _playerLoadedDataSubscription = null; + _playerEndedSubscription?.cancel(); + _playerEndedSubscription = null; + _playerSeekedSubscription?.cancel(); + _playerSeekedSubscription = null; + _playerPlaySubscription?.cancel(); + _playerPlaySubscription = null; + _playerErrorSubscription?.cancel(); + _playerErrorSubscription = null; + } + + Future start(double position) async { + _isPlaying = true; + if (_currentUrl == null) { + return; // nothing to play yet + } + if (player == null) { + recreateNode(); + } + player?.currentTime = position; + await player?.play().toDart; + } + + Future resume() async { + await start(_pausedAt ?? 0); + } + + void pause() { + // TODO(Gustl22): remove ignore, when web >= 1.0.0 + // ignore: unnecessary_cast + _pausedAt = player?.currentTime as double?; + _isPlaying = false; + player?.pause(); + } + + void stop() { + pause(); + _pausedAt = 0; + if (_currentReleaseMode == ReleaseMode.release) { + release(); + } else { + player?.currentTime = 0; + } + } + + void seek(int position) { + final seekPosition = position / 1000.0; + player?.currentTime = seekPosition; + + if (!_isPlaying) { + _pausedAt = seekPosition; + } + } + + void log(String message) { + eventStreamController.add( + AudioEvent(eventType: AudioEventType.log, logMessage: message), + ); + } + + Future dispose() async { + release(); + eventStreamController.close(); + } +} diff --git a/packages/audioplayers_web/pubspec.yaml b/packages/audioplayers_web/pubspec.yaml new file mode 100644 index 000000000..c86c43ea0 --- /dev/null +++ b/packages/audioplayers_web/pubspec.yaml @@ -0,0 +1,30 @@ +name: audioplayers_web +resolution: workspace +description: Web implementation of audioplayers, a Flutter plugin to play multiple audio files simultaneously +version: 5.1.1 +homepage: https://github.com/bluefireteam/audioplayers +repository: https://github.com/bluefireteam/audioplayers/tree/master/packages/audioplayers_web + +flutter: + plugin: + platforms: + web: + pluginClass: AudioplayersPlugin + fileName: audioplayers_web.dart + +dependencies: + audioplayers_platform_interface: ^7.1.1 + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + web: '>=0.5.1 <2.0.0' + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + +environment: + sdk: ^3.6.0 + flutter: '>=3.27.0' diff --git a/packages/audioplayers_windows/.gitignore b/packages/audioplayers_windows/.gitignore new file mode 100644 index 000000000..04c2400af --- /dev/null +++ b/packages/audioplayers_windows/.gitignore @@ -0,0 +1,17 @@ +*.iml +.DS_Store +.atom/ +.idea +.packages +.dart_tool/ +.pub/ +build/ +ios/.generated/ +packages +.classpath +.project +.settings +.vscode +testing +.flutter-plugins-dependencies +flutter_export_environment.sh diff --git a/packages/audioplayers_windows/.metadata b/packages/audioplayers_windows/.metadata new file mode 100644 index 000000000..434f1e5eb --- /dev/null +++ b/packages/audioplayers_windows/.metadata @@ -0,0 +1,10 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled and should not be manually edited. + +version: + revision: a0860f6e87ba4f9031bee4d6f56c08b970606bee + channel: dev + +project_type: plugin diff --git a/packages/audioplayers_windows/CHANGELOG.md b/packages/audioplayers_windows/CHANGELOG.md new file mode 100644 index 000000000..633a1e45d --- /dev/null +++ b/packages/audioplayers_windows/CHANGELOG.md @@ -0,0 +1,390 @@ +## 4.2.1 + + - **FIX**: Migrate to Melos v7 and Pub Workspaces ([#1929](https://github.com/bluefireteam/audioplayers/issues/1929)). ([9d0bfe0b](https://github.com/bluefireteam/audioplayers/commit/9d0bfe0be5cba0ce4fb3a75912b41117a8996bfe)) + +## 4.2.0 + + - **FEAT**: Dispose players on Hot restart (closes [#1120](https://github.com/bluefireteam/audioplayers/issues/1120)) ([#1905](https://github.com/bluefireteam/audioplayers/issues/1905)). ([92bcb19e](https://github.com/bluefireteam/audioplayers/commit/92bcb19e10c462cb749e1209c5e066efc5581728)) + +## 4.1.1 + + - **DOCS**: Fix status badge ([#1899](https://github.com/bluefireteam/audioplayers/issues/1899)). ([a0c6c4fa](https://github.com/bluefireteam/audioplayers/commit/a0c6c4fabff19e943dced1070c1be3541668dce9)) + +## 4.1.0 + + - **FIX**: No-op on single player setAudioContext in desktop platforms ([#1888](https://github.com/bluefireteam/audioplayers/issues/1888)). ([50d7a8b8](https://github.com/bluefireteam/audioplayers/commit/50d7a8b89f47e3ef29e98cf2b74a582f78783d5e)) + - **FEAT**: ReleaseMode.release for ios, macos, windows, web, linux ([#1790](https://github.com/bluefireteam/audioplayers/issues/1790)). ([4ffc4029](https://github.com/bluefireteam/audioplayers/commit/4ffc4029d846d7c391c457b829c372c1763b7b50)) + +## 4.0.0 + +> Note: This release has breaking changes. + + - **BREAKING** **FEAT**: FramePositionUpdater & TimerPositionUpdater ([#1664](https://github.com/bluefireteam/audioplayers/issues/1664)). ([1ea93536](https://github.com/bluefireteam/audioplayers/commit/1ea93536b448fa5d43281cbc0a7b67445fc1a9a8)) + - **BREAKING** **DEPS**: Update min Flutter to v3.13.0, compatibility with v3.16.8 ([#1715](https://github.com/bluefireteam/audioplayers/issues/1715)). ([e4262f4c](https://github.com/bluefireteam/audioplayers/commit/e4262f4c0d6582c35738ace603583c81bd5a3b4b)) + +## 3.1.0 + + - **REFACTOR**: Lint Kotlin, C and C++ code ([#1610](https://github.com/bluefireteam/audioplayers/issues/1610)). ([05394668](https://github.com/bluefireteam/audioplayers/commit/0539466850aaa49a0bde9448939c6c3d536dd6e2)) + - **FIX**: Improve Error handling for Unsupported Sources ([#1625](https://github.com/bluefireteam/audioplayers/issues/1625)). ([a4d84422](https://github.com/bluefireteam/audioplayers/commit/a4d84422f1421755b05aa7eff38b4d2ed0cf7482)) + - **FIX**: Return null for duration and position, if not available ([#1606](https://github.com/bluefireteam/audioplayers/issues/1606)). ([2a79644a](https://github.com/bluefireteam/audioplayers/commit/2a79644a2064ccc5d8e9a31aaf888b0b60ee321d)) + - **FEAT**(windows): Support for BytesSource on Windows ([#1601](https://github.com/bluefireteam/audioplayers/issues/1601)). ([a9e14710](https://github.com/bluefireteam/audioplayers/commit/a9e147107aa31072d4bcc69a02b2ee287d4b366b)) + - **FEAT**: Release source for Web, Linux, Windows ([#1517](https://github.com/bluefireteam/audioplayers/issues/1517)). ([09496dcb](https://github.com/bluefireteam/audioplayers/commit/09496dcbf478af330e37be833184439b43b5ac44)) + +## 3.0.0 + +> Note: This release has breaking changes. + + - **REFACTOR**(windows): simplify position and duration processing ([#1553](https://github.com/bluefireteam/audioplayers/issues/1553)). ([ca63c5a4](https://github.com/bluefireteam/audioplayers/commit/ca63c5a4b120e0d1ea421e6ab30f590c314a33f2)) + - **BREAKING** **CHORE**: Bump Flutter to version 3.10.x ([#1529](https://github.com/bluefireteam/audioplayers/issues/1529)). ([c1296c9b](https://github.com/bluefireteam/audioplayers/commit/c1296c9ba0cc43284b31d78f2f484454fbf6b773)) + +## 2.0.2 + + - **FIX**: Timeout on setting same source twice ([#1520](https://github.com/bluefireteam/audioplayers/issues/1520)). ([5d164d1f](https://github.com/bluefireteam/audioplayers/commit/5d164d1f20463a8a31a228cd1d85252d47ae256e)) + - **FIX**: test and fix compatibility with min flutter version ([#1510](https://github.com/bluefireteam/audioplayers/issues/1510)). ([9f39e95f](https://github.com/bluefireteam/audioplayers/commit/9f39e95ff7913d8fc30fff27fef7aefc32de26fb)) + - **FIX**: onPrepared event to wait until player is ready / finished loading the source ([#1469](https://github.com/bluefireteam/audioplayers/issues/1469)). ([50f56365](https://github.com/bluefireteam/audioplayers/commit/50f56365f8e512df0fc5bdb7222614389cbd4ea0)) + - **FIX**: rework dispose ([#1480](https://github.com/bluefireteam/audioplayers/issues/1480)). ([c64ef6d9](https://github.com/bluefireteam/audioplayers/commit/c64ef6d914a52743128c717b90c4da0abbd7538d)) + +## 2.0.1 + + - **FIX**: dispose player implementation ([#1470](https://github.com/bluefireteam/audioplayers/issues/1470)). ([d9026c15](https://github.com/bluefireteam/audioplayers/commit/d9026c1538cc83dfba5745771ad71c307b6da852)) + +## 2.0.0 + +> Note: This release has breaking changes. + + - **FEAT**(windows): show nuget download info explicitely in verbose mode ([#1449](https://github.com/bluefireteam/audioplayers/issues/1449)). ([136028fa](https://github.com/bluefireteam/audioplayers/commit/136028fa1cbcf38f80e9cc7ad78b3bb89d2c6d30)) + - **DOCS**: update AudioCache explanation, migration guide, replace package READMEs ([#1457](https://github.com/bluefireteam/audioplayers/issues/1457)). ([b8eb1974](https://github.com/bluefireteam/audioplayers/commit/b8eb197435631fafeaa9a26eb76aca8e43e86420)) + - **DOCS**: Fix LICENSE files for windows and linux ([#1431](https://github.com/bluefireteam/audioplayers/issues/1431)). ([1f84e857](https://github.com/bluefireteam/audioplayers/commit/1f84e857a112e663fff73c4e7c6875ebb72c783d)) + - **BREAKING** **FEAT**: event channel ([#1352](https://github.com/bluefireteam/audioplayers/issues/1352)). ([c9fd6a76](https://github.com/bluefireteam/audioplayers/commit/c9fd6a762c8c346d8d5598e3550c5571a5e460f0)) + +## 1.1.3 + +> Note: This release was an accidental bump. + +## 1.1.2 + + - Update a dependency to the latest release. + +## 1.1.1 + + - **FIX**: Duration precision on Windows ([#1342](https://github.com/bluefireteam/audioplayers/issues/1342)). ([3cda1a65](https://github.com/bluefireteam/audioplayers/commit/3cda1a65dc0425c332ed2eb3619cd88531f0ea49)) + - **DOCS**: Fix repos and homepages on pubspecs ([#1349](https://github.com/bluefireteam/audioplayers/issues/1349)). ([0bdde4d9](https://github.com/bluefireteam/audioplayers/commit/0bdde4d9f8f62487cdcfe96221216eba03b31060)) + +## 1.1.0 + + - **FIX**: send onDuration event when play/resume (#1245). ([8108ff42](https://github.com/bluefireteam/audioplayers/commit/8108ff42d05c7f995d8289345302c6ac6d298f67)) + - **FEAT**: select decoder automatically on windows (#1221). ([ff78a42f](https://github.com/bluefireteam/audioplayers/commit/ff78a42f842e146df7dc98d6d00ae27821355653)) + +## 1.0.0 + + - **FIX**: Windows Failed to seekTo longer than 3:30s (#1125). ([8db4dcaa](https://github.com/bluefireteam/audioplayers/commit/8db4dcaa1446e1442c63134df80b95af852c078f)) + - **FEAT**: Upgrade flame lint dependency (#1132). ([0d6dae3e](https://github.com/bluefireteam/audioplayers/commit/0d6dae3efc4a73abeb554fd0862d64fda0269066)) + +## 1.0.0-rc.3 + + - **FEAT**: Linux platform support (closes #798) (#1110). ([74616c54](https://github.com/bluefireteam/audioplayers/commit/74616c5471fb942d8f08c41de50c93d4387f8916)) + +## 1.0.0-rc.2 + +## 1.0.0-rc.1 + + - First release after federation + +# Changelog + +## 0.20.2 +- Fix bug with inversed log levels + +## 0.20.1 +- Fix enum parsing on release mode on android + +## 0.20.0 +- Fix android/kotlin build for old projects +- Add method to clearNotification +- Add currentPosition stream on web +- Add seek on web +- Add a proper Logger +- Make setPlaybackRate signature consistent +- Fix fatal exception on Android API < 21 in WrappedMediaPlayer.kt setAttributes +- Add clearNotification method + +## 0.19.1 +- Add missing awaits for AudioCache +- Fix Kotlin Core version to v1.6.0 +- Fix iOS warning +- Fix README link to audio_cache.md to work on pub +- Fix documentation referencing old class +- Add web support for audioPlayer.getCurrentPosition +- Add web support for audioPlayer.getDuration +- Add web support for audioPlayer.setPlaybackRate +- Fix local file playback in LOW_LATENCY mode on Android + +## 0.19.0 +- Refactor Notifications code (small breaking changes) +- AudioCache for web +- Fixing basic features for Android lower than API 23 +- Fixing error after playing music several times with AudioCache +- Re-organize folder and file structure on the Dart side (project layout) +- Re-organize folders into a mono-repo +- Fix several bugs + +## 0.18.3 +- Fix Float vs Double mixup on Swift that prevent non-integer values for volume/playback +- Fix open sink issue / resource leak + +## 0.18.2 +- Changing Android minSdk verison to 16 +- Improve build processes and other small bug fixes + +## 0.18.1 +- Fix kotlin config issue for some apps +- Fix warning from pub +- Fix iOS lock screen +- Fix setUrl method + +## 0.18.0 +- Stable null-safety release +- Removed all the `@deprecated` code blocks + +## 0.17.4 +- Fix java.lang.UnsupportedOperationException on read-only kotlin map + +## 0.17.3 +- Backport some code to old kt (for now) + +## 0.17.2 +- Fix macos compilation issue +- Fix android for non-kotlin projects + +## 0.17.1 +- Use better algorithm for speed modulation on iOS +- Extracted and refactored all the notifications code onto the new file +- Add more checks and make sure notifcations code is not ran when it shouldn't +- Add more useful info to the troubleshoot guide + +## 0.17.0 +- Swift conversion of the darwin code + +## 0.16.2 +- Overhauled our contributing guidelines +- Improve docs around player state +- Update dependencies versions + +## 0.16.1 +- Fix Exception thrown when calling audioPlayer.dispose +- Fix bug with AudioCache crash on iOS + +## 0.16.0 +- Implemented stream routing for iOS +- Call release on dispose +- Fix iOS build +- Breaking change audio cache prefix in order to allow override 'assets' + +## audioplayers 0.15.1 +- Fix web for release mode + +## audioplayers 0.15.0 +- Improve loop/readme for web support +- Audio cache support for web +- Re-adding partial web support + +## audioplayers 0.14.3 +- Add next and previous command for ios + +## audioplayers 0.14.2 +- Fix pubspec problem because of web file + +## audioplayers 0.14.1 +- Adding linter, tests and flutter_driver integration tests to a CI (github actions) +- Minor fixes to the APIs and documentation +- Fix restarting the playback of a failed AVPlayerItem +- Prevent exceptions when null values are passed to notifications center +- Prevent crash by checking if headlessServiceInitialized before invoking onNotificationBackgroundPlayerStateChanged + +## audioplayers 0.14.0 +- Adding macOs support +- ios:fix lack of seek completion handle +- ios Delay start fixed + +## audioplayers 0.13.7 +- Bump dependencies, improve gitignore +- Upgrade pubspec pattern + +## audioplayers 0.13.6 +- added `setPlaybackRate` feature for Android +- Automatic detect address is local or remote (thanks, @saeed-golshan) + +## audioplayers 0.13.5 +- fixed crash on iOS when `startHeadlessService()` wasn't called on `AudioPlayer` (by @JesseScott) + +## audioplayers 0.13.4 +- fixing missing cleanup on hot restart on Android +- Background notification updates on iOS + +## audioplayers 0.13.3 +- audio notification area fixes +- fix when other apps are playing sounds +- fix android race condition +- Support for registering plugin in background enviroment +- fix typos and docs + +## audioplayers 0.13.2 +- Handling plugin dealloc and onTimeInterval crashs (thanks @chedechao111) +- Audio position update when the audio is paused (thanks @bjornjacobs) + +## audioplayers 0.13.1 +- Added stayAwake feature (thanks, @danielR2001) +- Improved dispose method (thanks, @hugocbpassos) +- Added getCurrentPosition (thanks, @hariom08) +- Some bug fixes and small changes + +## audioplayers 0.13.0 +- Call onDurationChanged after setUrl() to be consistent with ios version (thanks @subhash279) +- Adding getDuration feature iOS/Android (thanks @alecorsino) + +## audioplayers 0.12.1 +- Fixes bug where the stream handlers were not called due to exception on the handler +- Proper error message when errors in the dart handler occurs + +## audioplayers 0.12.0 +- Update to path_provider 1.1.0 +- Upgrade to Swift 5 in example project setting (thanks @jerryzhoujw) + +## audioplayers 0.11.0 +- **Breaking change**. Migrate from the deprecated original Android Support + Library to AndroidX. This shouldn't result in any functional changes, but it + requires any Android apps using this plugin to [also + migrate](https://developer.android.com/jetpack/androidx/migrate) if they're + using the original support library. + +## audioplayers 0.10.1 +- Seek and play now works with milliseconds instead of second (thanks, @catoldcui and @erickzanardo) + +## audioplayers 0.10.0 +- Added a low latency api for android (thanks, @feroult) + +## audioplayers 0.9.0 +- Improved callbacks using Streams to allow for multiple subscibers (thanks, @LucasCLuk) +- Update uuid version to 2.0.0 (thanks, @BeMacized) + +## audioplayers 0.8.2 +- Update path_provider version (thanks, @apiraino) + +## audioplayers 0.8.1 +- Fix for duration when playing a stream +- Added respectSilence flag in audioplayers, or isNotification for play methos in audio_cache + False by default, to use player for local notification. Silent when device is in silent mode. + +## audioplayers 0.8.0 +- Allow setting seek position in play function (thanks @rob-patchett) +- Get duration from the underlaying asset instead of from AVPlayerItem (thanks @andressade) +- Adding player state (thanks @renancaraujo) +- Set the audio session to active (thanks @benwicks) +- Delay seek operations on Android until player is ready (thanks @jeffmikels) + +## audioplayers 0.7.8 +- Fix bug regarding name clash with other plugins (thanks @imtaehyun) + +## audioplayers 0.7.7 +- Fix bug when using nested files with audio cache (thanks @hotstu for reporting and @eclewlow for fixing) + +## audioplayers 0.7.6 +- Fix the nefarious bug of 'sound only playing through headphones' (thanks so much, @tsun424) + +## audioplayers 0.7.5 +- Fix SDK constraint for Dart 2.1 (thanks @snoofer and @sroddy) + +## audioplayers 0.7.4 +- Some more fixes to work without errors with Dart 2 stronger types + +## audioplayers 0.7.3 +- Support Android SDK 16-20 (thanks, @sroddy) +- Avoid restarting a looping player if is stopped (thanks, @sroddy) + +## audioplayers 0.7.2 +- Bug fixes for iOS + +## audioplayers 0.7.1 +- Formatting + +## audioplayers 0.7.0 + +- Improved lifecycle handling for android +- Big performance boots +- Allows for finer control of releasing (with setReleaseMode, setUrl, resume, release) +- Allows for setting the volume at any time (with setVolume) +- Added LOOP as a ReleaseMode options, making it significantly faster +- Some other refactorings + +## audioplayers 0.6.0 + +- Major Refactoring! +- Renaming everything to audioplayers (mind the s) +- Better logging +- Added AudioCache (imported from Flame) +- Adding tests! +- Adding better example +- Greatly improving README +- Lots of other minor tweaks + +## audioplayers 0.5.2 + +- don't call the onClomplete hook when you manually stop the audio + +## audioplayers 0.5.1 + +- fix for dart 2 (thanks to @efortuna) + +## audioplayers 0.5.0 + +- improves Android performance by not calling `prepare` on the main thread + +## audioplayers 0.4.1 + +- fix `seek` for iOS + +## audioplayers 0.4.0 + +- volume controls + +## audioplayers 0.3.0 + +- working on iOS (thanks @feroult <3) + +## audioplayers 0.2.0 + +- adding disable log option + +## audioplayers 0.1.0 + +- support for multiple audios simultaneously + +## 0.2.0 + +- support for local files + +## 0.1.0 + +- update to the current Plugin API +- move to https://github.com/rxlabz/audioplayer + +## 0.0.2 + +Separated handlers for position, duration, completion and errors + +- setDurationHandler(TimeChangeHandler handler) +- setPositionHandler(TimeChangeHandler handler) +- setCompletionHandler(VoidCallback callback) +- setErrorHandler(ErrorHandler handler) + +- new typedef +```dart +typedef void TimeChangeHandler(Duration duration); +typedef void ErrorHandler(String message); +``` + +## 0.0.1 + +- first POC : + - methods : play, pause, stop + - a globalHandler for position, duration, completion and errors diff --git a/packages/audioplayers_windows/LICENSE b/packages/audioplayers_windows/LICENSE new file mode 100644 index 000000000..1a581b05c --- /dev/null +++ b/packages/audioplayers_windows/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Blue Fire + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/audioplayers_windows/README.md b/packages/audioplayers_windows/README.md new file mode 100644 index 000000000..e98a4bd8c --- /dev/null +++ b/packages/audioplayers_windows/README.md @@ -0,0 +1,29 @@ +

+ + AudioPlayers + +

+ +--- + +# audioplayers_windows +

+ + + + +

+ +The Windows implementation of [`audioplayers`](https://pub.dev/packages/audioplayers). + +## Usage + +This package is [endorsed](https://flutter.dev/docs/development/packages-and-plugins/developing-packages#endorsed-federated-plugin), +which means you can simply use `audioplayers` normally. +This package will be automatically included in your app when you do, so you do not need to add it to your `pubspec.yaml`. + +## Setup for Windows + +Please follow the Flutter guide to [set up Flutter on Windows](https://docs.flutter.dev/get-started/install/windows#windows-setup). + +Optionally you can add the individual component `NuGet package manager` inside **Visual Studio** or **Visual Studio Build Tools**, otherwise it will be downloaded while building. diff --git a/packages/audioplayers_windows/analysis_options.yaml b/packages/audioplayers_windows/analysis_options.yaml new file mode 100644 index 000000000..85732fa02 --- /dev/null +++ b/packages/audioplayers_windows/analysis_options.yaml @@ -0,0 +1 @@ +include: package:flame_lint/analysis_options.yaml diff --git a/packages/audioplayers_windows/pubspec.yaml b/packages/audioplayers_windows/pubspec.yaml new file mode 100644 index 000000000..de0432291 --- /dev/null +++ b/packages/audioplayers_windows/pubspec.yaml @@ -0,0 +1,27 @@ +name: audioplayers_windows +resolution: workspace +description: Windows implementation of audioplayers, a Flutter plugin to play multiple audio files simultaneously +version: 4.2.1 +homepage: https://github.com/bluefireteam/audioplayers +repository: https://github.com/bluefireteam/audioplayers/tree/master/packages/audioplayers_windows + +flutter: + plugin: + implements: audioplayers + platforms: + windows: + pluginClass: AudioplayersWindowsPlugin + +dependencies: + audioplayers_platform_interface: ^7.1.1 + flutter: + sdk: flutter + +dev_dependencies: + flame_lint: ^1.4.1 + flutter_test: + sdk: flutter + +environment: + sdk: ^3.6.0 + flutter: '>=3.27.0' diff --git a/packages/audioplayers_windows/windows/.gitignore b/packages/audioplayers_windows/windows/.gitignore new file mode 100644 index 000000000..d896f143f --- /dev/null +++ b/packages/audioplayers_windows/windows/.gitignore @@ -0,0 +1,20 @@ +flutter/ + +# CLion build files. +cmake-build-debug + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ diff --git a/packages/audioplayers_windows/windows/CMakeLists.txt b/packages/audioplayers_windows/windows/CMakeLists.txt new file mode 100644 index 000000000..0943f70ff --- /dev/null +++ b/packages/audioplayers_windows/windows/CMakeLists.txt @@ -0,0 +1,61 @@ +cmake_minimum_required(VERSION 3.15) +set(PROJECT_NAME "audioplayers_windows") +set(WIL_VERSION "1.0.210803.1") +project(${PROJECT_NAME} LANGUAGES CXX) +include(FetchContent) + +# This value is used when generating builds using this plugin, so it must +# not be changed +set(PLUGIN_NAME "${PROJECT_NAME}_plugin") + +FetchContent_Declare(nuget + URL "https://dist.nuget.org/win-x86-commandline/v6.5.0/nuget.exe" + URL_HASH SHA256=d5fce5185de92b7356ea9264b997a620e35c6f6c3c061e471e0dc3a84b3d74fd + DOWNLOAD_NO_EXTRACT true +) + +find_program(NUGET nuget) +if (NOT NUGET) + message(STATUS "Nuget.exe not found, trying to download or use cached version.") + FetchContent_MakeAvailable(nuget) + set(NUGET ${nuget_SOURCE_DIR}/nuget.exe) +endif() + +execute_process(COMMAND + ${NUGET} install Microsoft.Windows.ImplementationLibrary -Version ${WIL_VERSION} -ExcludeVersion -OutputDirectory ${CMAKE_BINARY_DIR}/packages + WORKING_DIRECTORY ${CMAKE_BINARY_DIR} + RESULT_VARIABLE ret) +if (NOT ret EQUAL 0) + message(FATAL_ERROR "Failed to install nuget package Microsoft.Windows.ImplementationLibrary.${WIL_VERSION}") +endif() + +add_library(${PLUGIN_NAME} SHARED + "audioplayers_windows_plugin.cpp" + "audio_player.h" + "audio_player.cpp" + "audioplayers_helpers.h" + "event_stream_handler.h" + "MediaEngineExtension.h" + "MediaEngineExtension.cpp" + "MediaFoundationHelpers.h" + "MediaEngineWrapper.h" + "MediaEngineWrapper.cpp" +) +apply_standard_settings(${PLUGIN_NAME}) +set_target_properties(${PLUGIN_NAME} PROPERTIES + CXX_VISIBILITY_PRESET hidden) + +target_link_libraries(${PLUGIN_NAME} PRIVATE ${CMAKE_BINARY_DIR}/packages/Microsoft.Windows.ImplementationLibrary/build/native/Microsoft.Windows.ImplementationLibrary.targets) +target_link_libraries(${PLUGIN_NAME} PRIVATE Mfplat windowsapp) + +target_compile_definitions(${PLUGIN_NAME} PRIVATE FLUTTER_PLUGIN_IMPL) +target_include_directories(${PLUGIN_NAME} INTERFACE + "${CMAKE_CURRENT_SOURCE_DIR}/include") +target_link_libraries(${PLUGIN_NAME} PRIVATE flutter flutter_wrapper_plugin) +target_link_libraries(${PLUGIN_NAME} PRIVATE shlwapi) + +# List of absolute paths to libraries that should be bundled with the plugin +set(audioplayers_windows_bundled_libraries + "" + PARENT_SCOPE +) diff --git a/packages/audioplayers_windows/windows/MediaEngineExtension.cpp b/packages/audioplayers_windows/windows/MediaEngineExtension.cpp new file mode 100644 index 000000000..6b2de4704 --- /dev/null +++ b/packages/audioplayers_windows/windows/MediaEngineExtension.cpp @@ -0,0 +1,104 @@ +// Include prior to C++/WinRT Headers +#include + +// Windows Implementation Library +#include +#include + +// MediaFoundation headers +#include +#include +#include + +// STL headers +#include +#include + +#include +#include "MediaEngineWrapper.h" +#include "MediaFoundationHelpers.h" + +#include "MediaEngineExtension.h" + +using namespace Microsoft::WRL; + +namespace media { + +IFACEMETHODIMP MediaEngineExtension::CanPlayType( + BOOL /*isAudioOnly*/, + BSTR /*mimeType*/, + MF_MEDIA_ENGINE_CANPLAY* result) noexcept { + *result = MF_MEDIA_ENGINE_CANPLAY_NOT_SUPPORTED; + return S_OK; +} + +IFACEMETHODIMP MediaEngineExtension::BeginCreateObject( + BSTR /*url*/, + IMFByteStream* /*byteStream*/, + MF_OBJECT_TYPE type, + IUnknown** cancelCookie, + IMFAsyncCallback* callback, + IUnknown* state) noexcept try { + if (cancelCookie) { + *cancelCookie = nullptr; + } + winrt::com_ptr localSource; + { + auto lock = m_lock.lock(); + THROW_HR_IF(MF_E_SHUTDOWN, m_hasShutdown); + localSource = m_mfMediaSource; + } + + if (type == MF_OBJECT_MEDIASOURCE && localSource != nullptr) { + winrt::com_ptr asyncResult; + THROW_IF_FAILED(MFCreateAsyncResult(localSource.get(), callback, state, + asyncResult.put())); + THROW_IF_FAILED(asyncResult->SetStatus(S_OK)); + m_uriType = ExtensionUriType::CustomSource; + // Invoke the callback synchronously since no outstanding work is required. + THROW_IF_FAILED(callback->Invoke(asyncResult.get())); + } else { + THROW_HR(MF_E_UNEXPECTED); + } + + return S_OK; +} +CATCH_RETURN(); + +STDMETHODIMP MediaEngineExtension::CancelObjectCreation( + _In_ IUnknown* /*cancelCookie*/) noexcept { + // Cancellation not supported + return E_NOTIMPL; +} + +STDMETHODIMP MediaEngineExtension::EndCreateObject(IMFAsyncResult* result, + IUnknown** object) noexcept + try { + *object = nullptr; + if (m_uriType == ExtensionUriType::CustomSource) { + THROW_IF_FAILED(result->GetStatus()); + THROW_IF_FAILED(result->GetObject(object)); + m_uriType = ExtensionUriType::Unknown; + } else { + THROW_HR(MF_E_UNEXPECTED); + } + return S_OK; +} +CATCH_RETURN(); + +void MediaEngineExtension::SetMediaSource(IUnknown* mfMediaSource) { + auto lock = m_lock.lock(); + THROW_HR_IF(MF_E_SHUTDOWN, m_hasShutdown); + m_mfMediaSource.copy_from(mfMediaSource); +} + +// Break circular references. +void MediaEngineExtension::Shutdown() { + auto lock = m_lock.lock(); + if (!m_hasShutdown) { + m_mfMediaSource = nullptr; + m_hasShutdown = true; + } +} + +} // namespace media diff --git a/packages/audioplayers_windows/windows/MediaEngineExtension.h b/packages/audioplayers_windows/windows/MediaEngineExtension.h new file mode 100644 index 000000000..551daabb4 --- /dev/null +++ b/packages/audioplayers_windows/windows/MediaEngineExtension.h @@ -0,0 +1,41 @@ +#pragma once + +namespace media { + +// This implementation of IMFMediaEngineExtension is used to integrate a custom +// IMFMediaSource with the MediaEngine pipeline +class MediaEngineExtension + : public winrt::implements { + public: + MediaEngineExtension() = default; + ~MediaEngineExtension() override = default; + + // IMFMediaEngineExtension + IFACEMETHOD(CanPlayType) + (BOOL isAudioOnly, + BSTR mimeType, + MF_MEDIA_ENGINE_CANPLAY* result) noexcept override; + IFACEMETHOD(BeginCreateObject) + (BSTR url, + IMFByteStream* byteStream, + MF_OBJECT_TYPE type, + IUnknown** cancelCookie, + IMFAsyncCallback* callback, + IUnknown* state) noexcept override; + IFACEMETHOD(CancelObjectCreation)(IUnknown* cancelCookie) noexcept override; + IFACEMETHOD(EndCreateObject) + (IMFAsyncResult* result, IUnknown** object) noexcept override; + + // Public methods + void SetMediaSource(IUnknown* mfMediaSource); + void Shutdown(); + + private: + wil::critical_section m_lock; + enum class ExtensionUriType { Unknown = 0, CustomSource }; + ExtensionUriType m_uriType = ExtensionUriType::Unknown; + bool m_hasShutdown = false; + winrt::com_ptr m_mfMediaSource; +}; + +} // namespace media \ No newline at end of file diff --git a/packages/audioplayers_windows/windows/MediaEngineWrapper.cpp b/packages/audioplayers_windows/windows/MediaEngineWrapper.cpp new file mode 100644 index 000000000..8dacb2623 --- /dev/null +++ b/packages/audioplayers_windows/windows/MediaEngineWrapper.cpp @@ -0,0 +1,360 @@ +#include + +// Include prior to C++/WinRT Headers +#include + +// Windows Implementation Library +#include +#include + +// MediaFoundation headers +#include +#include +#include + +// STL headers +#include +#include + +#include +#include "MediaEngineWrapper.h" +#include "MediaFoundationHelpers.h" +#include "audioplayers_helpers.h" + +using namespace Microsoft::WRL; + +namespace media { + +namespace { +class MediaEngineCallbackHelper + : public winrt::implements { + public: + MediaEngineCallbackHelper( + std::function onLoadedCB, + MediaEngineWrapper::ErrorCB errorCB, + MediaEngineWrapper::BufferingStateChangeCB bufferingStateChangeCB, + std::function playbackEndedCB, + std::function seekCompletedCB) + : m_onLoadedCB(onLoadedCB), + m_errorCB(errorCB), + m_bufferingStateChangeCB(bufferingStateChangeCB), + m_playbackEndedCB(playbackEndedCB), + m_seekCompletedCB(seekCompletedCB) { + // Ensure that callbacks are valid + THROW_HR_IF(E_INVALIDARG, !m_onLoadedCB); + THROW_HR_IF(E_INVALIDARG, !m_errorCB); + THROW_HR_IF(E_INVALIDARG, !m_bufferingStateChangeCB); + THROW_HR_IF(E_INVALIDARG, !m_playbackEndedCB); + THROW_HR_IF(E_INVALIDARG, !m_seekCompletedCB); + } + virtual ~MediaEngineCallbackHelper() = default; + + void DetachParent() { + auto lock = m_lock.lock(); + m_detached = true; + m_onLoadedCB = nullptr; + m_errorCB = nullptr; + m_bufferingStateChangeCB = nullptr; + m_playbackEndedCB = nullptr; + m_seekCompletedCB = nullptr; + } + + // IMFMediaEngineNotify + IFACEMETHODIMP EventNotify(DWORD eventCode, + DWORD_PTR param1, + DWORD param2) noexcept override try { + auto lock = m_lock.lock(); + THROW_HR_IF(MF_E_SHUTDOWN, m_detached); + + switch ((MF_MEDIA_ENGINE_EVENT)eventCode) { + case MF_MEDIA_ENGINE_EVENT_LOADEDDATA: + m_onLoadedCB(); + break; + case MF_MEDIA_ENGINE_EVENT_ERROR: + m_errorCB((MF_MEDIA_ENGINE_ERR)param1, (HRESULT)param2); + break; + case MF_MEDIA_ENGINE_EVENT_CANPLAY: + m_bufferingStateChangeCB( + MediaEngineWrapper::BufferingState::HAVE_ENOUGH); + break; + case MF_MEDIA_ENGINE_EVENT_WAITING: + m_bufferingStateChangeCB( + MediaEngineWrapper::BufferingState::HAVE_NOTHING); + break; + case MF_MEDIA_ENGINE_EVENT_ENDED: + m_playbackEndedCB(); + break; + case MF_MEDIA_ENGINE_EVENT_SEEKED: + m_seekCompletedCB(); + break; + default: + break; + } + + return S_OK; + } + CATCH_RETURN(); + + private: + wil::critical_section m_lock; + std::function m_onLoadedCB; + MediaEngineWrapper::ErrorCB m_errorCB; + MediaEngineWrapper::BufferingStateChangeCB m_bufferingStateChangeCB; + std::function m_playbackEndedCB; + std::function m_seekCompletedCB; + bool m_detached = false; +}; +} // namespace + +// Public methods + +void MediaEngineWrapper::Initialize() { + RunSyncInMTA([&]() { CreateMediaEngine(); }); +} + +void MediaEngineWrapper::Pause() { + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + THROW_IF_FAILED(m_mediaEngine->Pause()); + }); +} + +void MediaEngineWrapper::Shutdown() { + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + THROW_IF_FAILED(m_mediaEngine->Shutdown()); + }); +} + +void MediaEngineWrapper::StartPlayingFrom(double timestampInSeconds) { + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + THROW_IF_FAILED(m_mediaEngine->SetCurrentTime(timestampInSeconds)); + THROW_IF_FAILED(m_mediaEngine->Play()); + }); +} + +void MediaEngineWrapper::Resume() { + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + THROW_IF_FAILED(m_mediaEngine->Play()); + }); +} + +void MediaEngineWrapper::SetBalance(double balance) { + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + + winrt::com_ptr mediaEngineEx = + m_mediaEngine.as(); + THROW_IF_FAILED(mediaEngineEx->SetBalance(balance)); + }); +} + +void MediaEngineWrapper::SetPlaybackRate(double playbackRate) { + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + THROW_IF_FAILED(m_mediaEngine->SetPlaybackRate(playbackRate)); + }); +} + +void MediaEngineWrapper::SetVolume(float volume) { + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + THROW_IF_FAILED(m_mediaEngine->SetVolume(volume)); + }); +} + +void MediaEngineWrapper::SetLooping(bool isLooping) { + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + THROW_IF_FAILED(m_mediaEngine->SetLoop(isLooping)); + }); +} + +bool MediaEngineWrapper::GetLooping() { + bool looping = false; + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + looping = m_mediaEngine->GetLoop(); + }); + return looping; +} + +void MediaEngineWrapper::SeekTo(double timestampInSeconds) { + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + THROW_IF_FAILED(m_mediaEngine->SetCurrentTime(timestampInSeconds)); + }); +} + +// Get media time in seconds, returns NaN if no duration is available. +double MediaEngineWrapper::GetMediaTime() { + double currentTimeInSeconds = std::numeric_limits::quiet_NaN(); + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + currentTimeInSeconds = m_mediaEngine->GetCurrentTime(); + }); + return currentTimeInSeconds; +} + +// Get duration in seconds, returns NaN if no duration is available. +double MediaEngineWrapper::GetDuration() { + double durationInSeconds = std::numeric_limits::quiet_NaN(); + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + if (m_mediaEngine == nullptr) { + return; + } + durationInSeconds = m_mediaEngine->GetDuration(); + }); + return durationInSeconds; +} + +// Get buffered ranges in milliseconds +std::vector> +MediaEngineWrapper::GetBufferedRanges() { + std::vector> result; + RunSyncInMTA([&]() { + auto lock = m_lock.lock(); + + if (m_mediaEngine == nullptr) { + return; + } + + winrt::com_ptr mediaTimeRange; + THROW_IF_FAILED(m_mediaEngine->GetBuffered(mediaTimeRange.put())); + + double start; + double end; + for (uint32_t i = 0; i < mediaTimeRange->GetLength(); i++) { + mediaTimeRange->GetStart(i, &start); + mediaTimeRange->GetEnd(i, &end); + result.push_back( + std::make_tuple(ConvertSecondsToMs(start), ConvertSecondsToMs(end))); + } + }); + return result; +} + +// Internal methods + +void MediaEngineWrapper::CreateMediaEngine() { + winrt::com_ptr classFactory; + winrt::com_ptr creationAttributes; + + m_platformRef.Startup(); + + THROW_IF_FAILED(MFCreateAttributes(creationAttributes.put(), 7)); + m_callbackHelper = winrt::make( + [&]() { this->OnLoaded(); }, + [&](MF_MEDIA_ENGINE_ERR error, HRESULT hr) { this->OnError(error, hr); }, + [&](BufferingState state) { this->OnBufferingStateChange(state); }, + [&]() { this->OnPlaybackEnded(); }, [&]() { this->OnSeekCompleted(); }); + THROW_IF_FAILED(creationAttributes->SetUnknown(MF_MEDIA_ENGINE_CALLBACK, + m_callbackHelper.get())); + THROW_IF_FAILED( + creationAttributes->SetUINT32(MF_MEDIA_ENGINE_CONTENT_PROTECTION_FLAGS, + MF_MEDIA_ENGINE_ENABLE_PROTECTED_CONTENT)); + THROW_IF_FAILED(creationAttributes->SetGUID( + MF_MEDIA_ENGINE_BROWSER_COMPATIBILITY_MODE, + MF_MEDIA_ENGINE_BROWSER_COMPATIBILITY_MODE_IE_EDGE)); + THROW_IF_FAILED(creationAttributes->SetUINT32(MF_MEDIA_ENGINE_AUDIO_CATEGORY, + AudioCategory_Media)); + + m_mediaEngineExtension = winrt::make_self(); + THROW_IF_FAILED(creationAttributes->SetUnknown(MF_MEDIA_ENGINE_EXTENSION, + m_mediaEngineExtension.get())); + + THROW_IF_FAILED(CoCreateInstance(CLSID_MFMediaEngineClassFactory, nullptr, + CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(classFactory.put()))); + m_mediaEngine = nullptr; + THROW_IF_FAILED(classFactory->CreateInstance(0, creationAttributes.get(), + m_mediaEngine.put())); +} + +void MediaEngineWrapper::SetMediaSource(IMFMediaSource* mediaSource) { + winrt::com_ptr sourceUnknown; + THROW_IF_FAILED( + mediaSource->QueryInterface(IID_PPV_ARGS(sourceUnknown.put()))); + m_mediaEngineExtension->SetMediaSource(sourceUnknown.get()); + + winrt::com_ptr mediaEngineEx = + m_mediaEngine.as(); + wil::unique_bstr source = wil::make_bstr(L"customSrc"); + THROW_IF_FAILED(mediaEngineEx->SetSource(source.get())); +} + +void MediaEngineWrapper::ReleaseMediaSource() { + m_mediaEngineExtension->SetMediaSource(nullptr); + m_mediaEngine->SetSource(nullptr); +} + +// Callback methods + +void MediaEngineWrapper::OnLoaded() { + if (m_initializedCB) { + m_initializedCB(); + } +} + +void MediaEngineWrapper::OnError(MF_MEDIA_ENGINE_ERR error, HRESULT hr) { + if (m_errorCB) { + m_errorCB(error, hr); + } +} + +void MediaEngineWrapper::OnBufferingStateChange(BufferingState state) { + if (m_bufferingStateChangeCB) { + m_bufferingStateChangeCB(state); + } +} + +void MediaEngineWrapper::OnPlaybackEnded() { + if (m_playbackEndedCB) { + m_playbackEndedCB(); + } +} + +void MediaEngineWrapper::OnSeekCompleted() { + if (m_seekCompletedCB) { + m_seekCompletedCB(); + } +} + +} // namespace media diff --git a/packages/audioplayers_windows/windows/MediaEngineWrapper.h b/packages/audioplayers_windows/windows/MediaEngineWrapper.h new file mode 100644 index 000000000..c0c92efe3 --- /dev/null +++ b/packages/audioplayers_windows/windows/MediaEngineWrapper.h @@ -0,0 +1,83 @@ +#pragma once + +#include + +#include "MediaEngineExtension.h" +#include "MediaFoundationHelpers.h" + +namespace media { + +// This class handles creation and management of the MediaFoundation +// MediaEngine. +// - It uses the provided IMFMediaSource to feed media samples into the +// MediaEngine pipeline. +class MediaEngineWrapper + : public winrt::implements { + public: + using ErrorCB = std::function; + + enum class BufferingState { HAVE_NOTHING = 0, HAVE_ENOUGH = 1 }; + using BufferingStateChangeCB = std::function; + + MediaEngineWrapper(std::function initializedCB, + ErrorCB errorCB, + BufferingStateChangeCB bufferingStateChangeCB, + std::function playbackEndedCB, + std::function seekCompletedCB) + : m_initializedCB(initializedCB), + m_errorCB(errorCB), + m_bufferingStateChangeCB(bufferingStateChangeCB), + m_playbackEndedCB(playbackEndedCB), + m_seekCompletedCB(seekCompletedCB) {} + ~MediaEngineWrapper() {} + + // Create the media engine + void Initialize(); + + // Initialize with the provided media source + void SetMediaSource(IMFMediaSource* mediaSource); + + // Release media resources + void ReleaseMediaSource(); + + // Stop playback and cleanup resources + void Pause(); + void Shutdown(); + + // Control various aspects of playback + void StartPlayingFrom(double timestampInSeconds); + void Resume(); + void SetPlaybackRate(double playbackRate); + void SetVolume(float volume); + void SetBalance(double balance); + void SetLooping(bool isLooping); + void SeekTo(double timeStamp); + + // Query the current playback position + double GetMediaTime(); + double GetDuration(); + + bool GetLooping(); + + std::vector> GetBufferedRanges(); + + private: + wil::critical_section m_lock; + std::function m_initializedCB; + ErrorCB m_errorCB; + BufferingStateChangeCB m_bufferingStateChangeCB; + std::function m_playbackEndedCB; + std::function m_seekCompletedCB; + MFPlatformRef m_platformRef; + winrt::com_ptr m_mediaEngine; + winrt::com_ptr m_mediaEngineExtension; + winrt::com_ptr m_callbackHelper; + void CreateMediaEngine(); + void OnLoaded(); + void OnError(MF_MEDIA_ENGINE_ERR error, HRESULT hr); + void OnBufferingStateChange(BufferingState state); + void OnPlaybackEnded(); + void OnSeekCompleted(); +}; + +} // namespace media diff --git a/packages/audioplayers_windows/windows/MediaFoundationHelpers.h b/packages/audioplayers_windows/windows/MediaFoundationHelpers.h new file mode 100644 index 000000000..15fbfb995 --- /dev/null +++ b/packages/audioplayers_windows/windows/MediaFoundationHelpers.h @@ -0,0 +1,131 @@ +#pragma once + +namespace media { + +class MFPlatformRef { + public: + MFPlatformRef() {} + + virtual ~MFPlatformRef() { Shutdown(); } + + void Startup() { + if (!m_started) { + THROW_IF_FAILED(MFStartup(MF_VERSION, MFSTARTUP_FULL)); + m_started = true; + } + } + + void Shutdown() { + if (m_started) { + THROW_IF_FAILED(MFShutdown()); + m_started = false; + } + } + + private: + bool m_started = false; +}; + +class MFCallbackBase + : public winrt::implements { + public: + MFCallbackBase(DWORD flags = 0, + DWORD queue = MFASYNC_CALLBACK_QUEUE_MULTITHREADED) + : m_flags(flags), m_queue(queue) {} + + DWORD GetQueue() const { return m_queue; } + DWORD GetFlags() const { return m_flags; } + + // IMFAsyncCallback methods + IFACEMETHODIMP GetParameters(_Out_ DWORD* flags, _Out_ DWORD* queue) { + *flags = m_flags; + *queue = m_queue; + return S_OK; + } + + private: + DWORD m_flags = 0; + DWORD m_queue = 0; +}; + +class SyncMFCallback : public MFCallbackBase { + public: + SyncMFCallback() { m_invokeEvent.create(); } + + void Wait(uint32_t timeout = INFINITE) { + if (!m_invokeEvent.wait(timeout)) { + THROW_HR(ERROR_TIMEOUT); + } + } + + IMFAsyncResult* GetResult() { return m_result.get(); } + + // IMFAsyncCallback methods + + IFACEMETHODIMP Invoke(_In_opt_ IMFAsyncResult* result) noexcept override try { + m_result.copy_from(result); + m_invokeEvent.SetEvent(); + return S_OK; + } + CATCH_RETURN(); + + private: + wil::unique_event m_invokeEvent; + winrt::com_ptr m_result; +}; + +class MFWorkItem : public MFCallbackBase { + public: + MFWorkItem(std::function callback, + DWORD flags = 0, + DWORD queue = MFASYNC_CALLBACK_QUEUE_MULTITHREADED) + : MFCallbackBase(flags, queue) { + m_callback = callback; + } + + // IMFAsyncCallback methods + + IFACEMETHODIMP Invoke(_In_opt_ IMFAsyncResult* /*result*/) noexcept override + try { + m_callback(); + return S_OK; + } + CATCH_RETURN(); + + private: + std::function m_callback; +}; + +inline void MFPutWorkItem(std::function callback) { + winrt::com_ptr workItem = winrt::make_self(callback); + THROW_IF_FAILED( + MFPutWorkItem2(workItem->GetQueue(), 0, workItem.get(), nullptr)); +} + +// Helper function for ensuring that the provided callback runs synchronously on +// a MTA thread. All MediaFoundation calls should be made on a MTA thread to +// avoid subtle deadlock bugs due to objects inadvertedly being created in a STA +inline void RunSyncInMTA(std::function callback) { + APTTYPE apartmentType = {}; + APTTYPEQUALIFIER qualifier = {}; + + THROW_IF_FAILED(CoGetApartmentType(&apartmentType, &qualifier)); + + if (apartmentType == APTTYPE_MTA) { + wil::unique_couninitialize_call unique_coinit; + if (qualifier == APTTYPEQUALIFIER_IMPLICIT_MTA) { + unique_coinit = wil::CoInitializeEx_failfast(COINIT_MULTITHREADED); + } + callback(); + } else { + wil::unique_event complete; + complete.create(); + MFPutWorkItem([&]() { + callback(); + complete.SetEvent(); + }); + complete.wait(); + } +} + +} // namespace media diff --git a/packages/audioplayers_windows/windows/audio_player.cpp b/packages/audioplayers_windows/windows/audio_player.cpp new file mode 100644 index 000000000..00baff8d7 --- /dev/null +++ b/packages/audioplayers_windows/windows/audio_player.cpp @@ -0,0 +1,302 @@ +#include "audio_player.h" + +#include +#include +#include +#include +#include +#include +#include // for SHCreateMemStream +#include +#include + +#include "audioplayers_helpers.h" + +#define STR_LINK_TROUBLESHOOTING \ + "https://github.com/bluefireteam/audioplayers/blob/main/troubleshooting.md" +#undef GetCurrentTime + +using namespace winrt; + +AudioPlayer::AudioPlayer( + std::string playerId, + flutter::MethodChannel* methodChannel, + EventStreamHandler<>* eventHandler) + : _playerId(playerId), + _methodChannel(methodChannel), + _eventHandler(eventHandler) { + m_mfPlatform.Startup(); + + // Callbacks invoked by the media engine wrapper + auto onError = std::bind(&AudioPlayer::OnMediaError, this, + std::placeholders::_1, std::placeholders::_2); + auto onBufferingStateChanged = + std::bind(&AudioPlayer::OnMediaStateChange, this, std::placeholders::_1); + auto onPlaybackEndedCB = std::bind(&AudioPlayer::OnPlaybackEnded, this); + auto onSeekCompletedCB = std::bind(&AudioPlayer::OnSeekCompleted, this); + auto onLoadedCB = std::bind(&AudioPlayer::SendInitialized, this); + + // Create and initialize the MediaEngineWrapper which manages media playback + m_mediaEngineWrapper = winrt::make_self( + onLoadedCB, onError, onBufferingStateChanged, onPlaybackEndedCB, + onSeekCompletedCB); + + m_mediaEngineWrapper->Initialize(); +} + +AudioPlayer::~AudioPlayer() {} + +// This method should be called asynchronously, to avoid freezing UI +void AudioPlayer::SetSourceUrl(std::string url) { + if (_url != url) { + _url = url; + _isInitialized = false; + + try { + // Create a source resolver to create an IMFMediaSource for the content + // URL. This will create an instance of an inbuilt OS media source for + // playback. An application can skip this step and instantiate a custom + // IMFMediaSource implementation instead. + winrt::com_ptr sourceResolver; + THROW_IF_FAILED(MFCreateSourceResolver(sourceResolver.put())); + constexpr uint32_t sourceResolutionFlags = + MF_RESOLUTION_MEDIASOURCE | + MF_RESOLUTION_CONTENT_DOES_NOT_HAVE_TO_MATCH_EXTENSION_OR_MIME_TYPE | + MF_RESOLUTION_READ; + MF_OBJECT_TYPE objectType = {}; + + winrt::com_ptr mediaSource; + THROW_IF_FAILED(sourceResolver->CreateObjectFromURL( + winrt::to_hstring(url).c_str(), sourceResolutionFlags, nullptr, + &objectType, reinterpret_cast(mediaSource.put_void()))); + + m_mediaEngineWrapper->SetMediaSource(mediaSource.get()); + } catch (const std::exception& ex) { + this->OnError("WindowsAudioError", + "Failed to set source. For troubleshooting, " + "see: " STR_LINK_TROUBLESHOOTING, + flutter::EncodableValue(ex.what())); + } catch (...) { + // Forward errors to event stream, as this is called asynchronously + this->OnError("WindowsAudioError", + "Failed to set source. For troubleshooting, " + "see: " STR_LINK_TROUBLESHOOTING, + flutter::EncodableValue("Unknown Error setting url to '" + + url + "'.")); + } + } else { + OnPrepared(true); + } +} + +void AudioPlayer::SetSourceBytes(std::vector bytes) { + _isInitialized = false; + _url.clear(); + size_t size = bytes.size(); + + try { + winrt::com_ptr sourceResolver; + THROW_IF_FAILED(MFCreateSourceResolver(sourceResolver.put())); + constexpr uint32_t sourceResolutionFlags = + MF_RESOLUTION_MEDIASOURCE | + MF_RESOLUTION_CONTENT_DOES_NOT_HAVE_TO_MATCH_EXTENSION_OR_MIME_TYPE | + MF_RESOLUTION_READ; + MF_OBJECT_TYPE objectType = {}; + + winrt::com_ptr mediaSource; + + IStream* pstm = + SHCreateMemStream(bytes.data(), static_cast(size)); + IMFByteStream* stream = NULL; + MFCreateMFByteStreamOnStream(pstm, &stream); + + sourceResolver->CreateObjectFromByteStream( + stream, nullptr, sourceResolutionFlags, nullptr, &objectType, + reinterpret_cast(mediaSource.put_void())); + m_mediaEngineWrapper->SetMediaSource(mediaSource.get()); + } catch (...) { + // Forward errors to event stream, as this is called asynchronously + this->OnError("WindowsAudioError", "Error setting bytes", nullptr); + } +} + +void AudioPlayer::OnMediaError(MF_MEDIA_ENGINE_ERR error, HRESULT hr) { + LOG_HR_MSG(hr, "MediaEngine error (%d)", error); + // TODO(Gustl22): adapt log message to dart error event, check stacktrace. + if (this->_eventHandler) { + _com_error err(hr); + + std::wstring wstr(err.ErrorMessage()); + + int size = WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, &wstr[0], + (int)wstr.size(), NULL, 0, NULL, NULL); + std::string ret = std::string(size, 0); + WideCharToMultiByte(CP_UTF8, WC_ERR_INVALID_CHARS, &wstr[0], + (int)wstr.size(), &ret[0], size, NULL, NULL); + + std::string message = "MediaEngine error"; + this->OnError(std::to_string(error), message, flutter::EncodableValue(ret)); + } +} + +void AudioPlayer::OnError(const std::string& code, + const std::string& message, + const flutter::EncodableValue& details) { + if (this->_eventHandler) { + this->_eventHandler->Error(code, message, details); + } +} + +void AudioPlayer::OnMediaStateChange( + media::MediaEngineWrapper::BufferingState bufferingState) { + if (bufferingState != + media::MediaEngineWrapper::BufferingState::HAVE_NOTHING) { + // TODO(Gustl22): add buffering state + } +} + +void AudioPlayer::OnPrepared(bool isPrepared) { + if (this->_eventHandler) { + this->_eventHandler->Success(std::make_unique( + flutter::EncodableMap({{flutter::EncodableValue("event"), + flutter::EncodableValue("audio.onPrepared")}, + {flutter::EncodableValue("value"), + flutter::EncodableValue(isPrepared)}}))); + } +} + +void AudioPlayer::OnPlaybackEnded() { + if (this->_eventHandler) { + this->_eventHandler->Success(std::make_unique( + flutter::EncodableMap({{flutter::EncodableValue("event"), + flutter::EncodableValue("audio.onComplete")}, + {flutter::EncodableValue("value"), + flutter::EncodableValue(true)}}))); + } + if (GetReleaseMode() == ReleaseMode::loop) { + Play(); + } else { + Stop(); + } +} + +void AudioPlayer::OnDurationUpdate() { + auto duration = m_mediaEngineWrapper->GetDuration(); + if (this->_eventHandler) { + this->_eventHandler->Success( + std::make_unique(flutter::EncodableMap( + {{flutter::EncodableValue("event"), + flutter::EncodableValue("audio.onDuration")}, + {flutter::EncodableValue("value"), + isnan(duration) + ? flutter::EncodableValue(std::monostate{}) + : flutter::EncodableValue(ConvertSecondsToMs(duration))}}))); + } +} + +void AudioPlayer::OnSeekCompleted() { + if (this->_eventHandler) { + this->_eventHandler->Success( + std::make_unique(flutter::EncodableMap( + {{flutter::EncodableValue("event"), + flutter::EncodableValue("audio.onSeekComplete")}, + {flutter::EncodableValue("value"), + flutter::EncodableValue(true)}}))); + } +} + +void AudioPlayer::OnLog(const std::string& message) { + this->_eventHandler->Success(std::make_unique( + flutter::EncodableMap({{flutter::EncodableValue("event"), + flutter::EncodableValue("audio.onLog")}, + {flutter::EncodableValue("value"), + flutter::EncodableValue(message)}}))); +} + +void AudioPlayer::SendInitialized() { + if (!this->_isInitialized) { + this->_isInitialized = true; + OnPrepared(true); + OnDurationUpdate(); + } +} + +void AudioPlayer::ReleaseMediaSource() { + if (_isInitialized) { + m_mediaEngineWrapper->Pause(); + } + m_mediaEngineWrapper->ReleaseMediaSource(); + _url.clear(); + _isInitialized = false; +} + +void AudioPlayer::Dispose() { + ReleaseMediaSource(); + m_mediaEngineWrapper->Shutdown(); + _methodChannel = nullptr; + _eventHandler = nullptr; +} + +void AudioPlayer::SetReleaseMode(ReleaseMode releaseMode) { + m_mediaEngineWrapper->SetLooping(releaseMode == ReleaseMode::loop); + _releaseMode = releaseMode; +} + +ReleaseMode AudioPlayer::GetReleaseMode() { + return _releaseMode; +} + +void AudioPlayer::SetVolume(double volume) { + if (volume > 1) { + volume = 1; + } else if (volume < 0) { + volume = 0; + } + m_mediaEngineWrapper->SetVolume((float)volume); +} + +void AudioPlayer::SetPlaybackSpeed(double playbackSpeed) { + m_mediaEngineWrapper->SetPlaybackRate(playbackSpeed); +} + +void AudioPlayer::SetBalance(double balance) { + m_mediaEngineWrapper->SetBalance(balance); +} + +void AudioPlayer::Play() { + m_mediaEngineWrapper->StartPlayingFrom(m_mediaEngineWrapper->GetMediaTime()); + OnDurationUpdate(); +} + +void AudioPlayer::Pause() { + m_mediaEngineWrapper->Pause(); +} + +void AudioPlayer::Stop() { + Pause(); + if (GetReleaseMode() == ReleaseMode::release) { + ReleaseMediaSource(); + } else { + SeekTo(0); + } +} + +void AudioPlayer::Resume() { + m_mediaEngineWrapper->Resume(); + OnDurationUpdate(); +} + +double AudioPlayer::GetPosition() { + if (!_isInitialized) { + return std::numeric_limits::quiet_NaN(); + } + return m_mediaEngineWrapper->GetMediaTime(); +} + +double AudioPlayer::GetDuration() { + return m_mediaEngineWrapper->GetDuration(); +} + +void AudioPlayer::SeekTo(double seek) { + m_mediaEngineWrapper->SeekTo(seek); +} diff --git a/packages/audioplayers_windows/windows/audio_player.h b/packages/audioplayers_windows/windows/audio_player.h new file mode 100644 index 000000000..3254f0454 --- /dev/null +++ b/packages/audioplayers_windows/windows/audio_player.h @@ -0,0 +1,130 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +#undef GetCurrentTime + +#include +#include +#include + +#include "winrt/Windows.System.h" + +// Include prior to C++/WinRT Headers +#include + +// Windows Implementation Library +#include +#include + +// MediaFoundation headers +#include +#include +#include +#include + +// STL headers +#include + +#include +#include +#include +#include +#include +#include + +#include "MediaEngineWrapper.h" +#include "MediaFoundationHelpers.h" +#include "event_stream_handler.h" + +using namespace winrt; + +enum ReleaseMode { stop, release, loop }; + +static std::unordered_map const releaseModeMap = { + {"ReleaseMode.stop", ReleaseMode::stop}, + {"ReleaseMode.release", ReleaseMode::release}, + {"ReleaseMode.loop", ReleaseMode::loop}}; + +class AudioPlayer { + public: + AudioPlayer(std::string playerId, + flutter::MethodChannel* methodChannel, + EventStreamHandler<>* eventHandler); + + void Dispose(); + + void ReleaseMediaSource(); + + void SetReleaseMode(ReleaseMode releaseMode); + + void SetVolume(double volume); + + void SetPlaybackSpeed(double playbackSpeed); + + void SetBalance(double balance); + + void Play(); + + void Pause(); + + void Stop(); + + void Resume(); + + ReleaseMode GetReleaseMode(); + + double GetPosition(); + + double GetDuration(); + + void SeekTo(double seek); + + void SetSourceBytes(std::vector bytes); + + void SetSourceUrl(std::string url); + + void OnLog(const std::string& message); + + void OnError(const std::string& code, + const std::string& message, + const flutter::EncodableValue& details); + + virtual ~AudioPlayer(); + + private: + // Media members + media::MFPlatformRef m_mfPlatform; + winrt::com_ptr m_mediaEngineWrapper; + + bool _isInitialized = false; + ReleaseMode _releaseMode = ReleaseMode::release; + std::string _url{}; + + void SendInitialized(); + + void OnMediaError(MF_MEDIA_ENGINE_ERR error, HRESULT hr); + + void OnMediaStateChange( + media::MediaEngineWrapper::BufferingState bufferingState); + + void OnPlaybackEnded(); + + void OnDurationUpdate(); + + void OnSeekCompleted(); + + void OnPrepared(bool isPrepared); + + std::string _playerId; + + flutter::MethodChannel* _methodChannel; + + EventStreamHandler<>* _eventHandler; +}; diff --git a/packages/audioplayers_windows/windows/audioplayers_helpers.h b/packages/audioplayers_windows/windows/audioplayers_helpers.h new file mode 100644 index 000000000..b35dc68fc --- /dev/null +++ b/packages/audioplayers_windows/windows/audioplayers_helpers.h @@ -0,0 +1,13 @@ +constexpr int64_t c_msPerSecond = 1000; + +template +inline int64_t ConvertSecondsToMs(SecondsT seconds) { + if (isinf(seconds)) + return 0; + return static_cast(seconds * c_msPerSecond); +} + +template +inline double ConvertMsToSeconds(MsT ms) { + return static_cast(ms) / c_msPerSecond; +} diff --git a/packages/audioplayers_windows/windows/audioplayers_windows_plugin.cpp b/packages/audioplayers_windows/windows/audioplayers_windows_plugin.cpp new file mode 100644 index 000000000..c84e8cbed --- /dev/null +++ b/packages/audioplayers_windows/windows/audioplayers_windows_plugin.cpp @@ -0,0 +1,296 @@ +#include "include/audioplayers_windows/audioplayers_windows_plugin.h" + +// This must be included before many other Windows headers. +#include + +// For getPlatformVersion; remove unless needed for your plugin implementation. +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#include "audio_player.h" +#include "audioplayers_helpers.h" + +namespace { + +using namespace flutter; + +template +T GetArgument(const std::string arg, const EncodableValue* args, T fallback) { + T result{fallback}; + const auto* arguments = std::get_if(args); + if (arguments) { + auto result_it = arguments->find(EncodableValue(arg)); + if (result_it != arguments->end()) { + if (!result_it->second.IsNull()) + result = std::get(result_it->second); + } + } + return result; +} + +class AudioplayersWindowsPlugin : public Plugin { + public: + static void RegisterWithRegistrar(PluginRegistrarWindows* registrar); + + AudioplayersWindowsPlugin(); + + virtual ~AudioplayersWindowsPlugin(); + + private: + std::map> audioPlayers; + + static inline BinaryMessenger* binaryMessenger; + static inline std::unique_ptr> methods{}; + static inline std::unique_ptr> globalMethods{}; + static inline std::unique_ptr> globalEvents{}; + + // Called when a method is called on this plugin's channel from Dart. + void HandleMethodCall(const MethodCall& method_call, + std::unique_ptr> result); + + void HandleGlobalMethodCall( + const MethodCall& method_call, + std::unique_ptr> result); + + void CreatePlayer(std::string playerId); + + AudioPlayer* GetPlayer(std::string playerId); + + void OnGlobalLog(const std::string& message); +}; + +// static +void AudioplayersWindowsPlugin::RegisterWithRegistrar( + PluginRegistrarWindows* registrar) { + binaryMessenger = registrar->messenger(); + methods = std::make_unique>( + binaryMessenger, "xyz.luan/audioplayers", + &StandardMethodCodec::GetInstance()); + globalMethods = std::make_unique>( + binaryMessenger, "xyz.luan/audioplayers.global", + &StandardMethodCodec::GetInstance()); + auto _globalEventChannel = std::make_unique>( + binaryMessenger, "xyz.luan/audioplayers.global/events", + &StandardMethodCodec::GetInstance()); + + auto plugin = std::make_unique(); + + methods->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleMethodCall(call, std::move(result)); + }); + + globalMethods->SetMethodCallHandler( + [plugin_pointer = plugin.get()](const auto& call, auto result) { + plugin_pointer->HandleGlobalMethodCall(call, std::move(result)); + }); + globalEvents = std::make_unique>(); + auto _obj_stm_handle = + static_cast*>(globalEvents.get()); + std::unique_ptr> _ptr{_obj_stm_handle}; + _globalEventChannel->SetStreamHandler(std::move(_ptr)); + + registrar->AddPlugin(std::move(plugin)); +} + +AudioplayersWindowsPlugin::AudioplayersWindowsPlugin() {} + +AudioplayersWindowsPlugin::~AudioplayersWindowsPlugin() {} + +void AudioplayersWindowsPlugin::HandleGlobalMethodCall( + const MethodCall& method_call, + std::unique_ptr> result) { + auto args = method_call.arguments(); + + if (method_call.method_name().compare("init") == 0) { + for (const auto& entry : audioPlayers) { + entry.second->Dispose(); + } + audioPlayers.clear(); + } else if (method_call.method_name().compare("setAudioContext") == 0) { + this->OnGlobalLog("Setting AudioContext is not supported on Windows"); + } else if (method_call.method_name().compare("emitLog") == 0) { + auto message = GetArgument("message", args, std::string()); + this->OnGlobalLog(message); + } else if (method_call.method_name().compare("emitError") == 0) { + auto code = GetArgument("code", args, std::string()); + auto message = GetArgument("message", args, std::string()); + globalEvents->Error(code, message, nullptr); + result->Success(); + } else { + result->NotImplemented(); + return; + } + + result->Success(); +} + +void AudioplayersWindowsPlugin::HandleMethodCall( + const MethodCall& method_call, + std::unique_ptr> result) { + auto args = method_call.arguments(); + + auto playerId = GetArgument("playerId", args, std::string()); + if (playerId.empty()) { + result->Error("WindowsAudioError", + "Call missing mandatory parameter playerId.", nullptr); + return; + } + + if (method_call.method_name().compare("create") == 0) { + CreatePlayer(playerId); + result->Success(); + return; + } + + auto player = GetPlayer(playerId); + if (!player) { + result->Error( + "WindowsAudioError", + "Player has not yet been created or has already been disposed.", + nullptr); + return; + } + + if (method_call.method_name().compare("pause") == 0) { + player->Pause(); + } else if (method_call.method_name().compare("resume") == 0) { + player->Resume(); + } else if (method_call.method_name().compare("stop") == 0) { + player->Stop(); + } else if (method_call.method_name().compare("release") == 0) { + player->ReleaseMediaSource(); + } else if (method_call.method_name().compare("seek") == 0) { + auto positionInMs = GetArgument( + "position", args, (int)ConvertSecondsToMs(player->GetPosition())); + player->SeekTo(ConvertMsToSeconds(positionInMs)); + } else if (method_call.method_name().compare("setSourceUrl") == 0) { + auto url = GetArgument("url", args, std::string()); + + if (url.empty()) { + result->Error("WindowsAudioError", "Null URL received on setSourceUrl", + nullptr); + return; + } + + std::thread(&AudioPlayer::SetSourceUrl, player, url).detach(); + } else if (method_call.method_name().compare("setSourceBytes") == 0) { + auto data = GetArgument>("bytes", args, + std::vector{}); + + if (data.empty()) { + result->Error("WindowsAudioError", + "Null bytes received on setSourceBytes", nullptr); + return; + } + + std::thread(&AudioPlayer::SetSourceBytes, player, data).detach(); + } else if (method_call.method_name().compare("getDuration") == 0) { + auto duration = player->GetDuration(); + result->Success(isnan(duration) + ? EncodableValue(std::monostate{}) + : EncodableValue(ConvertSecondsToMs(duration))); + return; + } else if (method_call.method_name().compare("setVolume") == 0) { + auto volume = GetArgument("volume", args, 1.0); + player->SetVolume(volume); + } else if (method_call.method_name().compare("getCurrentPosition") == 0) { + auto position = player->GetPosition(); + result->Success(isnan(position) + ? EncodableValue(std::monostate{}) + : EncodableValue(ConvertSecondsToMs(position))); + return; + } else if (method_call.method_name().compare("setPlaybackRate") == 0) { + auto playbackRate = GetArgument("playbackRate", args, 1.0); + player->SetPlaybackSpeed(playbackRate); + } else if (method_call.method_name().compare("setReleaseMode") == 0) { + auto releaseModeStr = + GetArgument("releaseMode", args, std::string()); + if (releaseModeStr.empty()) { + result->Error("WindowsAudioError", + "Error calling setReleaseMode, releaseMode cannot be null", + nullptr); + return; + } + auto releaseModeIt = releaseModeMap.find(releaseModeStr); + if (releaseModeIt != releaseModeMap.end()) { + player->SetReleaseMode(releaseModeIt->second); + } else { + result->Error("WindowsAudioError", + "Error calling setReleaseMode, releaseMode '" + + releaseModeStr + "' not known", + nullptr); + return; + } + } else if (method_call.method_name().compare("setPlayerMode") == 0) { + // windows doesn't have multiple player modes, so this should no-op + } else if (method_call.method_name().compare("setAudioContext") == 0) { + player->OnLog("Setting AudioContext is not supported on Windows"); + } else if (method_call.method_name().compare("setBalance") == 0) { + auto balance = GetArgument("balance", args, 0.0); + player->SetBalance(balance); + } else if (method_call.method_name().compare("emitLog") == 0) { + auto message = GetArgument("message", args, std::string()); + player->OnLog(message); + } else if (method_call.method_name().compare("emitError") == 0) { + auto code = GetArgument("code", args, std::string()); + auto message = GetArgument("message", args, std::string()); + player->OnError(code, message, nullptr); + } else if (method_call.method_name().compare("dispose") == 0) { + player->Dispose(); + audioPlayers.erase(playerId); + } else { + result->NotImplemented(); + return; + } + result->Success(); +} + +void AudioplayersWindowsPlugin::CreatePlayer(std::string playerId) { + auto eventChannel = std::make_unique>( + binaryMessenger, "xyz.luan/audioplayers/events/" + playerId, + &StandardMethodCodec::GetInstance()); + + auto eventHandler = new EventStreamHandler<>(); + auto _obj_stm_handle = + static_cast*>(eventHandler); + std::unique_ptr> _ptr{_obj_stm_handle}; + eventChannel->SetStreamHandler(std::move(_ptr)); + + auto player = + std::make_unique(playerId, methods.get(), eventHandler); + audioPlayers.insert(std::make_pair(playerId, std::move(player))); +} + +AudioPlayer* AudioplayersWindowsPlugin::GetPlayer(std::string playerId) { + auto searchPlayer = audioPlayers.find(playerId); + if (searchPlayer == audioPlayers.end()) { + return nullptr; + } + return searchPlayer->second.get(); +} + +void AudioplayersWindowsPlugin::OnGlobalLog(const std::string& message) { + globalEvents->Success(std::make_unique( + flutter::EncodableMap({{flutter::EncodableValue("event"), + flutter::EncodableValue("audio.onLog")}, + {flutter::EncodableValue("value"), + flutter::EncodableValue(message)}}))); +} + +} // namespace + +void AudioplayersWindowsPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar) { + AudioplayersWindowsPlugin::RegisterWithRegistrar( + PluginRegistrarManager::GetInstance() + ->GetRegistrar(registrar)); +} diff --git a/packages/audioplayers_windows/windows/event_stream_handler.h b/packages/audioplayers_windows/windows/event_stream_handler.h new file mode 100644 index 000000000..577f4c0ef --- /dev/null +++ b/packages/audioplayers_windows/windows/event_stream_handler.h @@ -0,0 +1,48 @@ +#include +#include + +#include + +using namespace flutter; + +template +class EventStreamHandler : public StreamHandler { + public: + EventStreamHandler() = default; + + virtual ~EventStreamHandler() = default; + + void Success(std::unique_ptr _data) { + std::unique_lock _ul(m_mtx); + if (m_sink.get()) + m_sink.get()->Success(*_data.get()); + } + + void Error(const std::string& error_code, + const std::string& error_message, + const T& error_details) { + std::unique_lock _ul(m_mtx); + if (m_sink.get()) + m_sink.get()->Error(error_code, error_message, error_details); + } + + protected: + std::unique_ptr> OnListenInternal( + const T* arguments, + std::unique_ptr>&& events) override { + std::unique_lock _ul(m_mtx); + m_sink = std::move(events); + return nullptr; + } + + std::unique_ptr> OnCancelInternal( + const T* arguments) override { + std::unique_lock _ul(m_mtx); + m_sink.release(); + return nullptr; + } + + private: + std::mutex m_mtx; + std::unique_ptr> m_sink; +}; \ No newline at end of file diff --git a/packages/audioplayers_windows/windows/include/audioplayers_windows/audioplayers_windows_plugin.h b/packages/audioplayers_windows/windows/include/audioplayers_windows/audioplayers_windows_plugin.h new file mode 100644 index 000000000..cdc1034ee --- /dev/null +++ b/packages/audioplayers_windows/windows/include/audioplayers_windows/audioplayers_windows_plugin.h @@ -0,0 +1,23 @@ +#ifndef FLUTTER_PLUGIN_AUDIOPLAYERS_WINDOWS_PLUGIN_H_ +#define FLUTTER_PLUGIN_AUDIOPLAYERS_WINDOWS_PLUGIN_H_ + +#include + +#ifdef FLUTTER_PLUGIN_IMPL +#define FLUTTER_PLUGIN_EXPORT __declspec(dllexport) +#else +#define FLUTTER_PLUGIN_EXPORT __declspec(dllimport) +#endif + +#if defined(__cplusplus) +extern "C" { +#endif + +FLUTTER_PLUGIN_EXPORT void AudioplayersWindowsPluginRegisterWithRegistrar( + FlutterDesktopPluginRegistrarRef registrar); + +#if defined(__cplusplus) +} // extern "C" +#endif + +#endif // FLUTTER_PLUGIN_AUDIOPLAYERS_WINDOWS_PLUGIN_H_ diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 000000000..cc111b875 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,47 @@ +name: audioplayers_workspace +repository: https://github.com/bluefireteam/audioplayers +workspace: + - packages/audioplayers + - packages/audioplayers/example + - packages/audioplayers/example/server + - packages/audioplayers_android + - packages/audioplayers_android_exo + - packages/audioplayers_darwin + - packages/audioplayers_linux + - packages/audioplayers_platform_interface + - packages/audioplayers_web + - packages/audioplayers_windows + +environment: + sdk: ^3.6.0 + +dev_dependencies: + melos: ^7.0.0-dev.8 + +melos: + command: + bootstrap: + environment: + sdk: ^3.6.0 + flutter: '>=3.27.0' + dev_dependencies: + flame_lint: ^1.4.1 + + scripts: + pub-outdated: + run: melos exec dart pub outdated + description: Run `dart pub outdated` for all packages. + + pub-upgrade: + run: melos exec dart pub upgrade --major-versions + description: Run `dart pub upgrade --major-versions` for all packages. + + test:select: + run: melos exec flutter test + packageFilters: + dirExists: test + description: Run `flutter test` for selected packages. + + test: + run: melos run test:select --no-select + description: Run all Flutter tests in this project. diff --git a/scripts/analyze.sh b/scripts/analyze.sh deleted file mode 100755 index bd506fd95..000000000 --- a/scripts/analyze.sh +++ /dev/null @@ -1,40 +0,0 @@ -#!/usr/bin/env bash - -function run_analyze() { - result=$(flutter pub get 2>&1) # Sadly a pub get can block up our actions as it will retry forever if a package is not found, but this should atleast report everything else. - if [ $? -ne 0 ]; then - echo "flutter pub get failed:" - echo "$result" - exit 1 - fi - - result=$(flutter pub run dart_code_metrics:metrics .) - if [ "$result" != "" ]; then - echo "flutter dart code metrics issues:" - echo "$result" - exit 1 - fi - - result=$(flutter analyze .) - if ! echo "$result" | grep -q "No issues found!"; then - echo "$result" - echo "flutter analyze issue:" - exit 1 - fi -} - -echo "Starting Flame Analyzer" -echo "-----------------------" -for file in $(find . -type f -name "pubspec.yaml"); do - dir=$(dirname $file) - cd $dir - echo "Analyzing $dir" - run_analyze - analyze_result=$? - if [ $analyze_result -ne 0 ]; then - exit $analyze_result - fi - cd $(cd -) -done - -exit 0 diff --git a/scripts/ci/setup-android.sh b/scripts/ci/setup-android.sh new file mode 100755 index 000000000..bcfbfd972 --- /dev/null +++ b/scripts/ci/setup-android.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env +set -e + +ANDROID_SDK_VERSION=${1:-35} # Default to 30 if no version is provided +ANDROID_SYSTEM_IMAGE_SOURCE=${2:-aosp_atd} +ANDROID_ABI=${3:-x86_64} + +echo "Enable KVM permissions" +# see: https://github.com/actions/runner-images/discussions/7191 +echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules +sudo udevadm control --reload-rules +sudo udevadm trigger --name-match=kvm + +echo "Setting up Android environment for API level $ANDROID_SDK_VERSION" + +# Set environment variables +export ANDROID_AVD_HOME="$HOME/.android/avd" +echo "ANDROID_AVD_HOME=$ANDROID_AVD_HOME" >> "$GITHUB_ENV" +echo "ANDROID_AVD_HOME: $ANDROID_AVD_HOME" +mkdir -p "$ANDROID_AVD_HOME" + +export ANDROID_CMDLINE_TOOLS="$ANDROID_HOME/cmdline-tools/latest/bin" +echo "ANDROID_CMDLINE_TOOLS=$ANDROID_CMDLINE_TOOLS" >> "$GITHUB_ENV" +echo "ANDROID_CMDLINE_TOOLS: $ANDROID_CMDLINE_TOOLS" + +echo "Install Android Emulator" +"$ANDROID_CMDLINE_TOOLS"/sdkmanager --install "emulator" + +AVD_IMAGE="system-images;android-$ANDROID_SDK_VERSION;$ANDROID_SYSTEM_IMAGE_SOURCE;$ANDROID_ABI" + +echo "Install Android System Image: $AVD_IMAGE" +echo "y" | "$ANDROID_CMDLINE_TOOLS"/sdkmanager --install "$AVD_IMAGE" + +echo "Create AVD with Image: $AVD_IMAGE" +echo "no" | "$ANDROID_CMDLINE_TOOLS"/avdmanager create avd --force --name emu --device "Nexus 5X" -k "$AVD_IMAGE" + +echo "List available AVDs" +"$ANDROID_HOME"/emulator/emulator -list-avds + +echo "Install platform tools" +"$ANDROID_CMDLINE_TOOLS"/sdkmanager "platform-tools" + +# Start Emulator +echo "Starting emulator" +nohup "$ANDROID_HOME"/emulator/emulator -avd emu -no-audio -no-snapshot -no-window & +"$ANDROID_HOME"/platform-tools/adb wait-for-device shell "while [[ -z \$(getprop sys.boot_completed) ]]; do sleep 1; done; input keyevent 82" +"$ANDROID_HOME"/platform-tools/adb devices +echo "Emulator started" diff --git a/scripts/dartdoc.sh b/scripts/dartdoc.sh deleted file mode 100755 index 80562ebe6..000000000 --- a/scripts/dartdoc.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash - -function run_dartdoc() { - result=$(flutter pub get 2>&1) # Sadly a pub get can block up our actions as it will retry forever if a package is not found, but this should atleast report everything else. - if [ $? -ne 0 ]; then - echo "flutter pub get failed:" - echo "$result" - exit 1 - fi - - flutter pub run dartdoc --no-auto-include-dependencies --quiet -} - -echo "Starting Flame Dartdoc" -echo "----------------------" -for file in $(find ./packages -maxdepth 2 -type f -name "pubspec.yaml"); do - dir=$(dirname $file) - cd $dir - echo "Generating dartdoc $dir" - run_dartdoc - dartdoc_result=$? - if [ $dartdoc_result -ne 0 ]; then - exit $dartdoc_result - fi - cd $(cd -) -done - -exit 0 diff --git a/scripts/format.sh b/scripts/format.sh deleted file mode 100755 index c0b70edd9..000000000 --- a/scripts/format.sh +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env bash - -function run_format() { - FORMAT_ISSUES=$(flutter format --set-exit-if-changed -n .) - if [ $? -eq 1 ]; then - echo "flutter format issues on" - echo $FORMAT_ISSUES - exit 1 - fi -} - -echo "Starting Flame Formatter" -echo "------------------------" -for file in $(find . -type f -name "pubspec.yaml"); do - dir=$(dirname $file) - cd $dir - echo "Formatting $dir" - run_format - format_result=$? - if [ $format_result -ne 0 ]; then - exit $format_result - fi - cd $(cd -) -done - -exit 0 diff --git a/scripts/lint.sh b/scripts/lint.sh deleted file mode 100755 index 6d92aaa24..000000000 --- a/scripts/lint.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -./scripts/format.sh -if [ $? -eq 1 ]; then - echo "Formatting failed!" - exit 1 -fi - -echo "" - -./scripts/analyze.sh -if [ $? -eq 1 ]; then - echo "Analyzing failed!" - exit 1 -fi - -echo "Succesfully linted!" diff --git a/scripts/test.sh b/scripts/test.sh deleted file mode 100755 index 50270d85a..000000000 --- a/scripts/test.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/usr/bin/env bash - -echo "Starting Flame Tester" -echo "---------------------" -for file in $(find . -type d -name "test"); do - dir=$(dirname $file) - cd $dir - if [ -f "./pubspec.yaml" ]; then - echo "Testing $dir" - flutter test - test_result=$? - if [ $test_result -ne 0 ]; then - exit $test_result - fi - fi - cd $(cd -) -done - -exit 0 diff --git a/troubleshooting.md b/troubleshooting.md index 756bca1dc..fde440754 100644 --- a/troubleshooting.md +++ b/troubleshooting.md @@ -1,58 +1,88 @@ -This file describes some common pitfalls and how to solve them. Please always refer to this before opening an issue. +# Troubleshooting -## Both platforms +This file describes some common pitfalls and how to solve them. Please always refer to this before opening an issue. -### Asset not available/not found when playing local files +Also, you can compare your app with our [example code](https://github.com/bluefireteam/audioplayers/tree/main/packages/audioplayers/example) or try to reproduce your issue there. +For that check out our [Contributing Guide](https://github.com/bluefireteam/audioplayers/blob/main/contributing.md), if you want to debug the app locally. -Flutter requires that files are specified on your pubspec.yaml file, under flutter > assets, check [this](https://github.com/luanpotter/bgug/blob/master/pubspec.yaml#L89) for an example. Also keep in mind that `AudioCache` class will look for files under the `assets` folder and this class must be used to play local files. +## Runtime Issues -### Error when playing remote URL +### Supported Formats / Encodings -The remote URL must be accessible and not be a redirect. If it's not an audio file, it does a redirect, it requires some headers, cookies or authentication, it will not work. Please bundle the file on the app or host it somewhere that properly provides the file. If you are having issues with playing audio from an URL, please first download the file and try running it locally. If the issue persists, then open the issue, including the file so we can test. Otherwise, it's an issue with your URL, not audioplayers. +Not all formats are supported by all platforms. +Essentially `audioplayers` is just centralized interface that communicate with native audio players on each platform. +We are not parsing the bytes of your song. Each platform has its own native support. +**Please do not open issues regarding encoding / audio format compatibility unless it is an AudioPlayers specific issue.** -### How to pause/stop audio? +You can check a list of supported formats below: -The basic class of this package is the AudioPlayer class, which represents a single player playing a single audio, and it has methods `pause` and `stop` and `resume` to be used as you wish. +- [Android](https://developer.android.com/guide/topics/media/media-formats.html) +- [iOS](https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/MultimediaPG/UsingAudio/UsingAudio.html#//apple_ref/doc/uid/TP40009767-CH2-SW33) +- [macOS](https://developer.apple.com/library/archive/documentation/MusicAudio/Conceptual/CoreAudioOverview/SupportedAudioFormatsMacOSX/SupportedAudioFormatsMacOSX.html#//apple_ref/doc/uid/TP40003577-CH7-SW1) +- Web: audio formats supported by the browser you are using ([more details](https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Audio_codecs)) +- [Windows](https://learn.microsoft.com/en-us/windows/win32/medfound/supported-media-formats-in-media-foundation) +- Linux: List of defined [audio types](https://gstreamer.freedesktop.org/documentation/plugin-development/advanced/media-types.html?gi-language=c#table-of-audio-types) and their according [Plugins](https://gstreamer.freedesktop.org/documentation/plugins_doc.html?gi-language=c) -If you are using the `AudioCache` class, though, it does not have a pause method because that class generates new audio players every time you play, in order to allow for simultaneously playing. So the `play` or `loop` methods on that class returns the instance of `AudioPlayer` created, and you can save that to a variable and call `pause`/`stop`/`resume` on that instead. There is also a mode where `AudioCache` uses the same, `fixedPlayer`, but that is also returned in the method. Please take a look at the docs and source code for the `AudioCache` class for more details. Also, cf. [this stack overflow question](https://stackoverflow.com/questions/59229935/when-using-flame-audioplayers-how-to-stop-audios-from-audiocache/59229936#59229936). +Also, there is no guarantee that the file extension matches the audio format. +A file encoded as Opus (`.ogg`) can easily be renamed to `.mp3`, but that doesn't mean it can be played by the platform's audio player. +Please verify that the real encoding / audio format is supported by analyzing the audio file (e.g. with [Aconvert](https://www.aconvert.com/analyze.html)). -### Build issues +### Issues with remote URLs -**Warning**: If you are having any sort of build issues, please read this first. +#### Unsafe HTTP -Our [CI](https://github.com/luanpotter/audioplayers/blob/master/.github/workflows/build.yaml) builds our example app using audioplayers for Android, iOS and web. So if the build is passing, any build errors (from android/ios sdk, gradle, java, kotlin, cocoa pods, swift, flutter, etc) is not a global issue and likely is something on your setup. +It is very common for mobile platforms to forbid non-HTTPS traffic due to its lack of encryption and severe security deficiency. However, there are ways to bypass this protection. -Before opening an issue, you **must** try these steps: +On iOS and macOS, edit your `.plist` and add: -1. Run this on your project and try again: -```bash -flutter clean -rm -rf build -rm -rf ~/.pub-cache +```xml +NSAppTransportSecurity + + NSAllowsArbitraryLoads + + +``` -flutter pub get +On Android, add `android:usesCleartextTraffic="true"` to your `AndroidManifest.xml` file located in `android/app/src/main/AndroidManifest.xml`: + +```xml + + + + + ... + + ``` -2. If the issue persists, clone the audioplayers repo and run the `example` app on the platform you are having issues. If it works, then there is something wrong with your project, and you can compare it to the `example` app to see what the problem is. -3. If the problem still persists, and no existing (open or closed) issue on this repo, no stack overflow question or existing discord discussion solves you problem, then you can open an issue. But you must follow the issue template, and refer to the problem on the example app (or start with its code and make only the necessary modifications to trigger the issue), not on your own app that we don't have access (because since step 2 the error must be reproducible on the example app). -4. Again, only open an issue if you reached step 3 and follow the issue template closely. Build issues that do not follow these steps will be warned and closed. -## Android +#### [Web] CORS Policy - - Can't play remote files on Android 9: Android 9 has changed some network security defaults, so it may prevent you from play files outside https by default, [this stackoverflow question](https://stackoverflow.com/questions/45940861/android-8-cleartext-http-traffic-not-permitted) is a good source to solving this. +To be able to play your own resources on Web you need to make sure your server has CORS support enabled or [temporarily disable](https://stackoverflow.com/a/74783428/5164462) the security feature in your browser. - - Some old Samsung devices have a bug that prevents certain types of audio from being played, [see more here](https://stackoverflow.com/questions/16238218/android-media-player-streaming-issue-on-samsung-devices). - - - minSdkVersion: we only support SDK version 23 or up. - - Supporting bellow API 23 was turning the maintenance of the package quite complicated. We looked on many sources before increasing the min version; for example, as you can see [here](https://www.appbrain.com/stats/top-android-sdk-versions), uses of SDK 16 and below are from less than 1% of users. +#### Audio Streams -Currently, we don't plan on decreasing minSdkVersion to less than 23. Jelly Bean (SDK 16) was launched almost 10 years ago. Using such an old piece of software can in of itself lead to many performance and security issues and bugs. Please update your devices' android version to at least 23, and you will be able to enjoy audioplayers without any issues :) +One of the know reasons for streams not playing is that the stream is being gziped by the server, as described [here](https://github.com/bluefireteam/audioplayers/issues/183). -## iOs +#### Redirections, Authentication & Local Confirmation - - Project does not compile with plugin: First check your xcode version, for some unknow reason compilation seens to fail when using older versions of xcode. Also, always try to compile the example app of this plugin, we try to keep the example app always updated and working, so if you can't compile it, the problem may be on your xcode version/configuration. +The remote URL must be accessible and not be a redirect. If it's not an audio file, it does a redirect, it requires some headers, cookies or authentication, it will not work. +Please bundle the file on the app or host it somewhere that properly provides the file. +If you are having issues with playing audio from an URL, please first download the file and try running it locally. +If the issue persists, then open the issue, including the file so we can test. Otherwise, it's an issue with your URL, not audioplayers. - - Audio doens't keep playing on the background: Apparently there is a required configuration for that to happen on your app, you add the following lines to your `info.plist`: +### Issues with local Assets and AudioCache + +[Flutter requires](https://docs.flutter.dev/ui/assets/assets-and-images) that assets are specified on your `pubspec.yaml` file, under `flutter > assets`; check [this](https://github.com/bluefireteam/audioplayers/blob/main/packages/audioplayers/example/pubspec.yaml#L29) for an example. + +**Note**: Make sure you have set the path to your asset correctly, see the [AudioCache](https://github.com/bluefireteam/audioplayers/blob/main/getting_started.md#audiocache) concept. + +### [iOS] Background Audio + +There is a required configuration to enable audio do be playing on the background; add the following lines to your `info.plist`: ``` UIBackgroundModes @@ -63,10 +93,53 @@ Currently, we don't plan on decreasing minSdkVersion to less than 23. Jelly Bean Or on XCode you can add it as a capability; more details [here](https://developer.apple.com/documentation/avfoundation/media_assets_playback_and_editing/creating_a_basic_video_player_ios_and_tvos/enabling_background_audio). - - Can't play stream audio: One of the know reasons for streams not playing on iOs, may be because the stream is been gziped by the server, as reported [here](https://github.com/luanpotter/audioplayers/issues/183). +### [iOS] Simulataneous AVPlayer Instantiation Limits + +While Apple does not provide a hard number on these limits, there seems to be a device-dependent upper limit of how many AVPlayers can be instantiated at one time. The package will output "PlatformException(DarwinAudioError, AVPlayerItem.Status.failed on setSourceUrl: Unknown error, null)" when this occurs. + +### [iOS, macOS] Urls or Paths without a file extension + +At the moment, the player of iOS and macOS (`AVPlayer` on Darwin) only accepts to play files with an extension (like `.mp3` or `.wav`). Make sure these are available or help us fix the issue #803. + +### Gapless Looping + +Depending on the file format and platform, when audioplayers uses the native implementation of the "looping" feature, there will be gaps between plays, which might not be noticeable for non-continuous SFX but will definitely be noticeable for looping songs. + +TODO(luan): break down alternatives here, low latency mode, audio pool, gapless_audioplayer, ocarina, etc. + +### [macOS] Outgoing Connections + +By default, macOS apps don't allow outgoing connections; so playing audio files/streams from the internet won't work. To fix this, add the following to the `.entitlements` files for your app: + +```xml +com.apple.security.network.client + +``` + +## Build Issues + +**Warning**: If you are having any sort of build issues, you must read this first. + +Our [CI](https://github.com/bluefireteam/audioplayers/blob/master/.github/workflows/build.yml) builds our example app using audioplayers for Android, iOS, Linux, macOS, Windows, and web. So if the build is passing, any build errors (from android/ios sdk, gradle, java, kotlin, cocoa pods, swift, flutter, etc) is not a global issue and likely is something on your setup. + +Before opening an issue, you **must** try these steps: + +1. Run this on your project and try again: +```bash +flutter clean +rm -rf build +rm -rf ~/.pub-cache + +flutter pub get +``` +1. Update xcode, android studio, android sdks, to the latest versions. +1. If the issue persists, clone the audioplayers repo and run the `example` app on the platform you are having issues. If it works, then there is something wrong with your project, and you can compare it to the `example` app to see what the problem is. +1. If the problem still persists, and no existing (open or closed) issue on this repo, no stack overflow question or existing discord discussion solves you problem, then you can open an issue. But you must follow the issue template, and refer to the problem on the example app (or start with its code and make only the necessary modifications to trigger the issue), not on your own app that we don't have access (because since step 2 the error must be reproducible on the example app). +1. Again, only open an issue if you reached step 3 and follow the issue template closely. Build issues that do not follow these steps will be closed without warning. - ## macOS +### Build Requirements - - Project does not compile with plugin: First check your xcode version, for some unknow reason compilation seens to fail when using older versions of xcode. Also, always try to compile the example app of this plugin, we try to keep the example app always updated and working, so if you can't compile it, the problem may be on your xcode version/configuration. +Some platforms need additional requirements to be fulfilled: - - Can't play stream audio: One of the reasons for streams not playing on macOS, may be because the stream is been gziped by the server, as reported [here](https://github.com/luanpotter/audioplayers/issues/183). +* [Linux](packages/audioplayers_linux/README.md#setup-for-linux) (`audioplayers_linux`). +* [Windows](packages/audioplayers_windows/README.md#setup-for-windows) (`audioplayers_windows`).