├── .gitignore ├── LICENSE.txt ├── README.md ├── build.gradle ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── privacy.txt ├── proguard-project.txt └── src └── main ├── AndroidManifest.xml ├── assets └── notice.txt ├── java └── com │ └── kaytat │ └── simpleprotocolplayer │ ├── AudioFocusHelper.java │ ├── BackgroundMusicService.java │ ├── BufferToAudioTrackThread.java │ ├── MainActivity.java │ ├── MusicFocusable.java │ ├── MusicIntentReceiver.java │ ├── MusicService.java │ ├── NetworkReadThread.java │ ├── NoticeActivity.java │ ├── SppPlayer.java │ ├── ThreadStoppable.java │ ├── WifiLockManager.java │ └── WorkerThreadPair.java └── res ├── drawable-hdpi ├── ic_launcher.png ├── ic_stat_playing.png ├── play.png ├── play_pressed.png ├── stop.png └── stop_pressed.png ├── drawable-mdpi ├── ic_launcher.png └── ic_stat_playing.png ├── drawable-xhdpi ├── ic_launcher.png └── ic_stat_playing.png ├── drawable ├── btn_play.xml └── btn_stop.xml ├── layout ├── main.xml └── notice.xml ├── menu └── actions.xml └── values ├── strings.xml └── styles.xml /.gitignore: -------------------------------------------------------------------------------- 1 | # Built application files 2 | *.apk 3 | *.ap_ 4 | 5 | # Files for the Dalvik VM 6 | *.dex 7 | 8 | # Java class files 9 | *.class 10 | 11 | # Generated files 12 | bin/ 13 | gen/ 14 | 15 | # Gradle files 16 | .gradle/ 17 | build/ 18 | */build/ 19 | 20 | # Local configuration file (sdk path, etc) 21 | local.properties 22 | 23 | # Proguard folder generated by Eclipse 24 | proguard/ 25 | 26 | # Log Files 27 | *.log 28 | 29 | # Android Studio Navigation editor temp files 30 | .navigation/ 31 | 32 | # Android Studio captures folder 33 | captures/ 34 | 35 | .idea 36 | 37 | *.iml 38 | 39 | release/output.json 40 | debug/output-metadata.json 41 | release/output-metadata.json 42 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Simple Protocol Player 2 | 3 | ## Links: 4 | 5 | #### Play store 6 | 7 | https://play.google.com/store/apps/details?id=com.kaytat.simpleprotocolplayer&hl=en 8 | 9 | #### Server source 10 | 11 | https://github.com/kaytat/SimpleProtocolServer 12 | 13 | #### More info 14 | 15 | http://kaytat.com/blog/ 16 | 17 | ## Details 18 | 19 | This media player plays uncompressed PCM data from a server over your local network. This is meant 20 | to be used for streaming audio from a PC to your Android phone or tablet. The focus has been latency 21 | and so all the options are intended for the user to be able to find a compromise between latency and 22 | quality. 23 | 24 | As a rule of thumb, a low sample rate + mono + 50 ms or less of buffer time are the settings that 25 | are generally used. 26 | 27 | This project is based on an old version of this Android 28 | example: https://github.com/googlesamples/android-MediaBrowserService 29 | 30 | ### Changes for 0.5.7.0 (version 13) 31 | 32 | * Allow 33 | [PERFORMANCE_MODE_LOW_LATENCY](https://developer.android.com/reference/android/media/AudioTrack#PERFORMANCE_MODE_LOW_LATENCY) 34 | for Android O and above 35 | * Allow use of 36 | [minimal buffer size](https://developer.android.com/reference/android/media/AudioTrack#getMinBufferSize(int,%20int,%20int)) 37 | as reported by AudioTrack. 38 | * The idea here is to allow Android to dictate the buffer size and so the user don't have to 39 | tweak the ms delay setting. 40 | 41 | #### Streaming from Ubuntu (or anything running PulseAudio) 42 | 43 | The following web page describes how to configure PulseAudio for use with this 44 | player. http://kaytat.com/blog/?page_id=301 45 | 46 | #### Streaming with Pipewire 47 | 48 | * On demand with enabled pipewire-pulse using `pactl` from pulseaudio-utils: 49 | `pactl load-module module-simple-protocol-tcp rate=44100 format=s16le channels=2 record=true port=4711` 50 | 51 | * Permanently through config file `/etc/pipewire/pipewire.conf.d/simple-protocol.conf`: 52 | 53 | ``` 54 | context.modules = [ 55 | { name = libpipewire-module-protocol-simple 56 | args = { 57 | capture = true 58 | audio.rate = 44100 59 | audio.format = S16LE 60 | audio.channels = 2 61 | audio.position = [ FL FR ] 62 | server.address = [ 63 | "tcp:4711" 64 | ] 65 | } 66 | } 67 | ] 68 | ``` 69 | 70 | (https://docs.pipewire.org/page_module_protocol_simple.html for reference) 71 | 72 | #### Streaming from Windows 73 | 74 | Download the server from the github link above and run it locally. The server has some options also 75 | to help tune the performance. 76 | 77 | #### Test your latency: 78 | 79 | https://www.youtube.com/watch?v=KWh9YLtbbws 80 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | buildscript { 3 | repositories { 4 | // Gradle 4.1 and higher include support for Google's Maven repo using 5 | // the google() method. And you need to include this repo to download 6 | // Android Gradle plugin 3.0.0 or higher. 7 | google() 8 | mavenCentral() 9 | } 10 | dependencies { 11 | classpath 'com.android.tools.build:gradle:8.3.0' 12 | } 13 | } 14 | allprojects { 15 | repositories { 16 | google() 17 | mavenCentral() 18 | } 19 | } 20 | apply plugin: 'com.android.application' 21 | android { 22 | compileSdk 34 23 | defaultConfig { 24 | applicationId "com.kaytat.simpleprotocolplayer" 25 | minSdkVersion 26 26 | targetSdkVersion 34 27 | } 28 | buildTypes { 29 | release { 30 | minifyEnabled true 31 | proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-project.txt' 32 | } 33 | } 34 | productFlavors { 35 | } 36 | namespace 'com.kaytat.simpleprotocolplayer' 37 | } 38 | 39 | dependencies { 40 | implementation 'androidx.appcompat:appcompat:1.6.1' 41 | implementation 'androidx.core:core:1.12.0' 42 | implementation 'com.google.android.material:material:1.11.0' 43 | implementation 'commons-validator:commons-validator:1.7' 44 | implementation 'androidx.media3:media3-session:1.2.1' 45 | } 46 | 47 | configurations { 48 | configureEach { 49 | exclude module: 'commons-logging' 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | android.defaults.buildfeatures.buildconfig=true 2 | android.nonFinalResIds=true 3 | android.nonTransitiveRClass=true 4 | android.useAndroidX=true 5 | org.gradle.unsafe.configuration-cache=true 6 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Oct 29 16:24:45 PDT 2016 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-all.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 10 | DEFAULT_JVM_OPTS="" 11 | 12 | APP_NAME="Gradle" 13 | APP_BASE_NAME=$(basename "$0") 14 | 15 | # Use the maximum available, or set MAX_FD != -1 to use that value. 16 | MAX_FD="maximum" 17 | 18 | warn() { 19 | echo "$*" 20 | } 21 | 22 | die() { 23 | echo 24 | echo "$*" 25 | echo 26 | exit 1 27 | } 28 | 29 | # OS specific support (must be 'true' or 'false'). 30 | cygwin=false 31 | msys=false 32 | darwin=false 33 | case "$(uname)" in 34 | CYGWIN*) 35 | cygwin=true 36 | ;; 37 | Darwin*) 38 | darwin=true 39 | ;; 40 | MINGW*) 41 | msys=true 42 | ;; 43 | esac 44 | 45 | # For Cygwin, ensure paths are in UNIX format before anything is touched. 46 | if $cygwin; then 47 | [ -n "$JAVA_HOME" ] && JAVA_HOME=$(cygpath --unix "$JAVA_HOME") 48 | fi 49 | 50 | # Attempt to set APP_HOME 51 | # Resolve links: $0 may be a link 52 | PRG="$0" 53 | # Need this for relative symlinks. 54 | while [ -h "$PRG" ]; do 55 | ls=$(ls -ld "$PRG") 56 | link=$(expr "$ls" : '.*-> \(.*\)$') 57 | if expr "$link" : '/.*' >/dev/null; then 58 | PRG="$link" 59 | else 60 | PRG=$(dirname "$PRG")"/$link" 61 | fi 62 | done 63 | SAVED="$(pwd)" 64 | cd "$(dirname \"$PRG\")/" >&- 65 | APP_HOME="$(pwd -P)" 66 | cd "$SAVED" >&- 67 | 68 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 69 | 70 | # Determine the Java command to use to start the JVM. 71 | if [ -n "$JAVA_HOME" ]; then 72 | if [ -x "$JAVA_HOME/jre/sh/java" ]; then 73 | # IBM's JDK on AIX uses strange locations for the executables 74 | JAVACMD="$JAVA_HOME/jre/sh/java" 75 | else 76 | JAVACMD="$JAVA_HOME/bin/java" 77 | fi 78 | if [ ! -x "$JAVACMD" ]; then 79 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 80 | 81 | Please set the JAVA_HOME variable in your environment to match the 82 | location of your Java installation." 83 | fi 84 | else 85 | JAVACMD="java" 86 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 87 | 88 | Please set the JAVA_HOME variable in your environment to match the 89 | location of your Java installation." 90 | fi 91 | 92 | # Increase the maximum file descriptors if we can. 93 | if [ "$cygwin" = "false" -a "$darwin" = "false" ]; then 94 | MAX_FD_LIMIT=$(ulimit -H -n) 95 | if [ $? -eq 0 ]; then 96 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ]; then 97 | MAX_FD="$MAX_FD_LIMIT" 98 | fi 99 | ulimit -n $MAX_FD 100 | if [ $? -ne 0 ]; then 101 | warn "Could not set maximum file descriptor limit: $MAX_FD" 102 | fi 103 | else 104 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 105 | fi 106 | fi 107 | 108 | # For Darwin, add options to specify how the application appears in the dock 109 | if $darwin; then 110 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 111 | fi 112 | 113 | # For Cygwin, switch paths to Windows format before running java 114 | if $cygwin; then 115 | APP_HOME=$(cygpath --path --mixed "$APP_HOME") 116 | CLASSPATH=$(cygpath --path --mixed "$CLASSPATH") 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=$(find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null) 120 | SEP="" 121 | for dir in $ROOTDIRSRAW; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ]; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@"; do 133 | CHECK=$(echo "$arg" | egrep -c "$OURCYGPATTERN" -) 134 | CHECK2=$(echo "$arg" | egrep -c "^-") ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ]; then ### Added a condition 137 | eval $(echo args$i)=$(cygpath --path --ignore --mixed "$arg") 138 | else 139 | eval $(echo args$i)="\"$arg\"" 140 | fi 141 | i=$((i + 1)) 142 | done 143 | case $i in 144 | 0) set -- ;; 145 | 1) set -- "$args0" ;; 146 | 2) set -- "$args0" "$args1" ;; 147 | 3) set -- "$args0" "$args1" "$args2" ;; 148 | 4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules 158 | function splitJvmOpts() { 159 | JVM_OPTS=("$@") 160 | } 161 | eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS 162 | JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" 163 | 164 | exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" 165 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 12 | set DEFAULT_JVM_OPTS= 13 | 14 | set DIRNAME=%~dp0 15 | if "%DIRNAME%" == "" set DIRNAME=. 16 | set APP_BASE_NAME=%~n0 17 | set APP_HOME=%DIRNAME% 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windowz variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | if "%@eval[2+2]" == "4" goto 4NT_args 53 | 54 | :win9xME_args 55 | @rem Slurp the command line arguments. 56 | set CMD_LINE_ARGS= 57 | set _SKIP=2 58 | 59 | :win9xME_args_slurp 60 | if "x%~1" == "x" goto execute 61 | 62 | set CMD_LINE_ARGS=%* 63 | goto execute 64 | 65 | :4NT_args 66 | @rem Get arguments from the 4NT Shell from JP Software 67 | set CMD_LINE_ARGS=%$ 68 | 69 | :execute 70 | @rem Setup the command line 71 | 72 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 73 | 74 | @rem Execute Gradle 75 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 76 | 77 | :end 78 | @rem End local scope for the variables with windows NT shell 79 | if "%ERRORLEVEL%"=="0" goto mainEnd 80 | 81 | :fail 82 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 83 | rem the _cmd.exe /c_ return code! 84 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 85 | exit /b 1 86 | 87 | :mainEnd 88 | if "%OS%"=="Windows_NT" endlocal 89 | 90 | :omega 91 | -------------------------------------------------------------------------------- /privacy.txt: -------------------------------------------------------------------------------- 1 | Simple Protocol Player does not collect, use, or share user data. 2 | -------------------------------------------------------------------------------- /proguard-project.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/proguard-project.txt -------------------------------------------------------------------------------- /src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 17 | 18 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 35 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 49 | 50 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | -------------------------------------------------------------------------------- /src/main/assets/notice.txt: -------------------------------------------------------------------------------- 1 | ------------------------------------------------------------ 2 | This app uses samples from the Android SDK. 3 | 4 | 5 | Copyright (c) 2005-2008, The Android Open Source Project 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | 16 | 17 | Apache License 18 | Version 2.0, January 2004 19 | http://www.apache.org/licenses/ 20 | 21 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 22 | 23 | 1. Definitions. 24 | 25 | "License" shall mean the terms and conditions for use, reproduction, 26 | and distribution as defined by Sections 1 through 9 of this document. 27 | 28 | "Licensor" shall mean the copyright owner or entity authorized by 29 | the copyright owner that is granting the License. 30 | 31 | "Legal Entity" shall mean the union of the acting entity and all 32 | other entities that control, are controlled by, or are under common 33 | control with that entity. For the purposes of this definition, 34 | "control" means (i) the power, direct or indirect, to cause the 35 | direction or management of such entity, whether by contract or 36 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 37 | outstanding shares, or (iii) beneficial ownership of such entity. 38 | 39 | "You" (or "Your") shall mean an individual or Legal Entity 40 | exercising permissions granted by this License. 41 | 42 | "Source" form shall mean the preferred form for making modifications, 43 | including but not limited to software source code, documentation 44 | source, and configuration files. 45 | 46 | "Object" form shall mean any form resulting from mechanical 47 | transformation or translation of a Source form, including but 48 | not limited to compiled object code, generated documentation, 49 | and conversions to other media types. 50 | 51 | "Work" shall mean the work of authorship, whether in Source or 52 | Object form, made available under the License, as indicated by a 53 | copyright notice that is included in or attached to the work 54 | (an example is provided in the Appendix below). 55 | 56 | "Derivative Works" shall mean any work, whether in Source or Object 57 | form, that is based on (or derived from) the Work and for which the 58 | editorial revisions, annotations, elaborations, or other modifications 59 | represent, as a whole, an original work of authorship. For the purposes 60 | of this License, Derivative Works shall not include works that remain 61 | separable from, or merely link (or bind by name) to the interfaces of, 62 | the Work and Derivative Works thereof. 63 | 64 | "Contribution" shall mean any work of authorship, including 65 | the original version of the Work and any modifications or additions 66 | to that Work or Derivative Works thereof, that is intentionally 67 | submitted to Licensor for inclusion in the Work by the copyright owner 68 | or by an individual or Legal Entity authorized to submit on behalf of 69 | the copyright owner. For the purposes of this definition, "submitted" 70 | means any form of electronic, verbal, or written communication sent 71 | to the Licensor or its representatives, including but not limited to 72 | communication on electronic mailing lists, source code control systems, 73 | and issue tracking systems that are managed by, or on behalf of, the 74 | Licensor for the purpose of discussing and improving the Work, but 75 | excluding communication that is conspicuously marked or otherwise 76 | designated in writing by the copyright owner as "Not a Contribution." 77 | 78 | "Contributor" shall mean Licensor and any individual or Legal Entity 79 | on behalf of whom a Contribution has been received by Licensor and 80 | subsequently incorporated within the Work. 81 | 82 | 2. Grant of Copyright License. Subject to the terms and conditions of 83 | this License, each Contributor hereby grants to You a perpetual, 84 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 85 | copyright license to reproduce, prepare Derivative Works of, 86 | publicly display, publicly perform, sublicense, and distribute the 87 | Work and such Derivative Works in Source or Object form. 88 | 89 | 3. Grant of Patent License. Subject to the terms and conditions of 90 | this License, each Contributor hereby grants to You a perpetual, 91 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 92 | (except as stated in this section) patent license to make, have made, 93 | use, offer to sell, sell, import, and otherwise transfer the Work, 94 | where such license applies only to those patent claims licensable 95 | by such Contributor that are necessarily infringed by their 96 | Contribution(s) alone or by combination of their Contribution(s) 97 | with the Work to which such Contribution(s) was submitted. If You 98 | institute patent litigation against any entity (including a 99 | cross-claim or counterclaim in a lawsuit) alleging that the Work 100 | or a Contribution incorporated within the Work constitutes direct 101 | or contributory patent infringement, then any patent licenses 102 | granted to You under this License for that Work shall terminate 103 | as of the date such litigation is filed. 104 | 105 | 4. Redistribution. You may reproduce and distribute copies of the 106 | Work or Derivative Works thereof in any medium, with or without 107 | modifications, and in Source or Object form, provided that You 108 | meet the following conditions: 109 | 110 | (a) You must give any other recipients of the Work or 111 | Derivative Works a copy of this License; and 112 | 113 | (b) You must cause any modified files to carry prominent notices 114 | stating that You changed the files; and 115 | 116 | (c) You must retain, in the Source form of any Derivative Works 117 | that You distribute, all copyright, patent, trademark, and 118 | attribution notices from the Source form of the Work, 119 | excluding those notices that do not pertain to any part of 120 | the Derivative Works; and 121 | 122 | (d) If the Work includes a "NOTICE" text file as part of its 123 | distribution, then any Derivative Works that You distribute must 124 | include a readable copy of the attribution notices contained 125 | within such NOTICE file, excluding those notices that do not 126 | pertain to any part of the Derivative Works, in at least one 127 | of the following places: within a NOTICE text file distributed 128 | as part of the Derivative Works; within the Source form or 129 | documentation, if provided along with the Derivative Works; or, 130 | within a display generated by the Derivative Works, if and 131 | wherever such third-party notices normally appear. The contents 132 | of the NOTICE file are for informational purposes only and 133 | do not modify the License. You may add Your own attribution 134 | notices within Derivative Works that You distribute, alongside 135 | or as an addendum to the NOTICE text from the Work, provided 136 | that such additional attribution notices cannot be construed 137 | as modifying the License. 138 | 139 | You may add Your own copyright statement to Your modifications and 140 | may provide additional or different license terms and conditions 141 | for use, reproduction, or distribution of Your modifications, or 142 | for any such Derivative Works as a whole, provided Your use, 143 | reproduction, and distribution of the Work otherwise complies with 144 | the conditions stated in this License. 145 | 146 | 5. Submission of Contributions. Unless You explicitly state otherwise, 147 | any Contribution intentionally submitted for inclusion in the Work 148 | by You to the Licensor shall be under the terms and conditions of 149 | this License, without any additional terms or conditions. 150 | Notwithstanding the above, nothing herein shall supersede or modify 151 | the terms of any separate license agreement you may have executed 152 | with Licensor regarding such Contributions. 153 | 154 | 6. Trademarks. This License does not grant permission to use the trade 155 | names, trademarks, service marks, or product names of the Licensor, 156 | except as required for reasonable and customary use in describing the 157 | origin of the Work and reproducing the content of the NOTICE file. 158 | 159 | 7. Disclaimer of Warranty. Unless required by applicable law or 160 | agreed to in writing, Licensor provides the Work (and each 161 | Contributor provides its Contributions) on an "AS IS" BASIS, 162 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 163 | implied, including, without limitation, any warranties or conditions 164 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 165 | PARTICULAR PURPOSE. You are solely responsible for determining the 166 | appropriateness of using or redistributing the Work and assume any 167 | risks associated with Your exercise of permissions under this License. 168 | 169 | 8. Limitation of Liability. In no event and under no legal theory, 170 | whether in tort (including negligence), contract, or otherwise, 171 | unless required by applicable law (such as deliberate and grossly 172 | negligent acts) or agreed to in writing, shall any Contributor be 173 | liable to You for damages, including any direct, indirect, special, 174 | incidental, or consequential damages of any character arising as a 175 | result of this License or out of the use or inability to use the 176 | Work (including but not limited to damages for loss of goodwill, 177 | work stoppage, computer failure or malfunction, or any and all 178 | other commercial damages or losses), even if such Contributor 179 | has been advised of the possibility of such damages. 180 | 181 | 9. Accepting Warranty or Additional Liability. While redistributing 182 | the Work or Derivative Works thereof, You may choose to offer, 183 | and charge a fee for, acceptance of support, warranty, indemnity, 184 | or other liability obligations and/or rights consistent with this 185 | License. However, in accepting such obligations, You may act only 186 | on Your own behalf and on Your sole responsibility, not on behalf 187 | of any other Contributor, and only if You agree to indemnify, 188 | defend, and hold each Contributor harmless for any liability 189 | incurred by, or claims asserted against, such Contributor by reason 190 | of your accepting any such warranty or additional liability. 191 | 192 | END OF TERMS AND CONDITIONS 193 | 194 | 195 | ------------------------------------------------------------ 196 | Icons 197 | 198 | http://www.flaticon.com - designed by Flaticon.com 199 | 200 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/AudioFocusHelper.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * Copyright (C) 2014 kaytat 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package com.kaytat.simpleprotocolplayer; 19 | 20 | import android.content.Context; 21 | import android.media.AudioFocusRequest; 22 | import android.media.AudioManager; 23 | 24 | /** 25 | * Convenience class to deal with audio focus. This class deals with everything related to audio 26 | * focus: it can request and abandon focus, and will intercept focus change events and deliver them 27 | * to a MusicFocusable interface (which, in our case, is implemented by {@link MusicService}). 28 | */ 29 | public class AudioFocusHelper implements AudioManager.OnAudioFocusChangeListener { 30 | final AudioManager mAM; 31 | final MusicFocusable mFocusable; 32 | final AudioFocusRequest mAudioFocusRequest; 33 | 34 | public AudioFocusHelper(Context ctx, MusicFocusable focusable) { 35 | mAM = (AudioManager) ctx.getSystemService(Context.AUDIO_SERVICE); 36 | mFocusable = focusable; 37 | mAudioFocusRequest = 38 | new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN) 39 | .setOnAudioFocusChangeListener(this) 40 | .build(); 41 | } 42 | 43 | /** Requests audio focus. Returns whether request was successful or not. */ 44 | public boolean requestFocus() { 45 | return AudioManager.AUDIOFOCUS_REQUEST_GRANTED == mAM.requestAudioFocus(mAudioFocusRequest); 46 | } 47 | 48 | /** Abandons audio focus. Returns whether request was successful or not. */ 49 | public boolean abandonFocus() { 50 | return AudioManager.AUDIOFOCUS_REQUEST_GRANTED 51 | == mAM.abandonAudioFocusRequest(mAudioFocusRequest); 52 | } 53 | 54 | /** 55 | * Called by AudioManager on audio focus changes. We implement this by calling our MusicFocusable 56 | * appropriately to relay the message. 57 | */ 58 | public void onAudioFocusChange(int focusChange) { 59 | if (mFocusable == null) { 60 | return; 61 | } 62 | switch (focusChange) { 63 | case AudioManager.AUDIOFOCUS_GAIN: 64 | mFocusable.onGainedAudioFocus(); 65 | break; 66 | case AudioManager.AUDIOFOCUS_LOSS: 67 | case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT: 68 | mFocusable.onLostAudioFocus(false); 69 | break; 70 | case AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK: 71 | mFocusable.onLostAudioFocus(true); 72 | break; 73 | default: 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/BackgroundMusicService.java: -------------------------------------------------------------------------------- 1 | package com.kaytat.simpleprotocolplayer; 2 | 3 | import android.content.Intent; 4 | import androidx.annotation.NonNull; 5 | import androidx.annotation.Nullable; 6 | import androidx.annotation.OptIn; 7 | import androidx.media3.common.Player; 8 | import androidx.media3.common.util.UnstableApi; 9 | import androidx.media3.session.MediaSession; 10 | import androidx.media3.session.MediaSessionService; 11 | 12 | public class BackgroundMusicService extends MediaSessionService { 13 | private MediaSession mediaSession; 14 | 15 | @OptIn(markerClass = UnstableApi.class) 16 | @Override 17 | public void onCreate() { 18 | super.onCreate(); 19 | mediaSession = new MediaSession.Builder(this, new SppPlayer(this)).build(); 20 | } 21 | 22 | @Nullable 23 | public MediaSession onGetSession(@NonNull MediaSession.ControllerInfo controllerInfo) { 24 | return mediaSession; 25 | } 26 | 27 | @Override 28 | public void onTaskRemoved(@Nullable Intent rootIntent) { 29 | Player player = mediaSession.getPlayer(); 30 | if (!player.getPlayWhenReady() || player.getMediaItemCount() == 0) { 31 | // Stop the service if not playing, continue playing in the background 32 | // otherwise. 33 | stopSelf(); 34 | } 35 | } 36 | 37 | @Override 38 | public void onDestroy() { 39 | mediaSession.getPlayer().release(); 40 | mediaSession.release(); 41 | mediaSession = null; 42 | super.onDestroy(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/BufferToAudioTrackThread.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * Copyright (C) 2014 kaytat 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package com.kaytat.simpleprotocolplayer; 19 | 20 | import android.media.AudioTrack; 21 | import android.util.Log; 22 | 23 | /** 24 | * Worker thread that takes data from the buffer and sends it to audio track 25 | */ 26 | class BufferToAudioTrackThread extends ThreadStoppable { 27 | final String TAG; 28 | 29 | private final WorkerThreadPair syncObject; 30 | 31 | public BufferToAudioTrackThread(WorkerThreadPair syncObject, 32 | String debugTag) { 33 | this.setName(debugTag); 34 | TAG = debugTag; 35 | this.mTrack = syncObject.audioTrack; 36 | this.syncObject = syncObject; 37 | } 38 | 39 | // Media track 40 | private AudioTrack mTrack; 41 | 42 | @Override 43 | public void run() { 44 | Log.i(TAG, "start"); 45 | 46 | mTrack.play(); 47 | 48 | try { 49 | while (running) { 50 | mTrack.write(syncObject.dataQueue.take(), 0, 51 | syncObject.bytesPerAudioPacket); 52 | } 53 | } catch (Exception e) { 54 | Log.e(TAG, "exception:" + e); 55 | } 56 | 57 | // Do some cleanup 58 | mTrack.stop(); 59 | mTrack.release(); 60 | mTrack = null; 61 | Log.i(TAG, "done"); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/MainActivity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * Copyright (C) 2014 kaytat 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | * 17 | * Code for NoFilter related from here: 18 | * http://stackoverflow.com/questions/8512762/autocompletetextview-disable 19 | * -filtering 20 | */ 21 | 22 | package com.kaytat.simpleprotocolplayer; 23 | 24 | import android.annotation.SuppressLint; 25 | import android.content.ComponentName; 26 | import android.content.Context; 27 | import android.content.Intent; 28 | import android.content.SharedPreferences; 29 | import android.content.res.Resources; 30 | import android.net.ConnectivityManager; 31 | import android.net.NetworkCapabilities; 32 | import android.net.Uri; 33 | import android.os.Bundle; 34 | import android.util.Log; 35 | import android.view.Menu; 36 | import android.view.MenuInflater; 37 | import android.view.MenuItem; 38 | import android.view.View; 39 | import android.view.View.OnClickListener; 40 | import android.view.WindowManager; 41 | import android.view.inputmethod.InputMethodManager; 42 | import android.widget.ArrayAdapter; 43 | import android.widget.AutoCompleteTextView; 44 | import android.widget.Button; 45 | import android.widget.CheckBox; 46 | import android.widget.EditText; 47 | import android.widget.Filter; 48 | import android.widget.Spinner; 49 | import android.widget.Toast; 50 | import androidx.annotation.NonNull; 51 | import androidx.appcompat.app.AppCompatActivity; 52 | import androidx.media3.common.MediaItem; 53 | import androidx.media3.common.MediaMetadata; 54 | import androidx.media3.session.MediaController; 55 | import androidx.media3.session.SessionToken; 56 | import com.google.common.base.Strings; 57 | import com.google.common.util.concurrent.ListenableFuture; 58 | import com.google.common.util.concurrent.MoreExecutors; 59 | import java.util.ArrayList; 60 | import java.util.List; 61 | import java.util.Locale; 62 | import org.apache.commons.validator.routines.DomainValidator; 63 | import org.apache.commons.validator.routines.InetAddressValidator; 64 | import org.json.JSONArray; 65 | import org.json.JSONException; 66 | import org.json.JSONObject; 67 | 68 | /** 69 | * Main activity: shows media player buttons. This activity shows the media player buttons and lets 70 | * the user click them. No media handling is done here -- everything is done by passing Intents to 71 | * our {@link MusicService}. 72 | */ 73 | public class MainActivity extends AppCompatActivity implements OnClickListener { 74 | private static final String TAG = "MainActivity"; 75 | 76 | AutoCompleteTextView ipAddrText; 77 | ArrayList ipAddrList; 78 | ArrayAdapter ipAddrAdapter; 79 | 80 | AutoCompleteTextView audioPortText; 81 | ArrayList audioPortList; 82 | ArrayAdapter audioPortAdapter; 83 | 84 | int sampleRate; 85 | boolean stereo; 86 | int bufferMs; 87 | boolean retry; 88 | boolean usePerformanceMode; 89 | boolean useMinBuffer; 90 | 91 | Button playButton; 92 | Button stopButton; 93 | 94 | private enum NetworkConnection { 95 | NOT_CONNECTED, 96 | WIFI_CONNECTED, 97 | NON_WIFI_CONNECTED 98 | } 99 | 100 | /** 101 | * Called when the activity is first created. Here, we simply set the event listeners and start 102 | * the background service ({@link MusicService}) that will handle the actual media playback. 103 | */ 104 | @SuppressLint("ClickableViewAccessibility") 105 | @Override 106 | public void onCreate(Bundle savedInstanceState) { 107 | super.onCreate(savedInstanceState); 108 | setContentView(R.layout.main); 109 | 110 | ipAddrText = findViewById(R.id.editTextIpAddr); 111 | audioPortText = findViewById(R.id.editTextAudioPort); 112 | 113 | playButton = findViewById(R.id.playButton); 114 | stopButton = findViewById(R.id.stopButton); 115 | 116 | playButton.setOnClickListener(this); 117 | stopButton.setOnClickListener(this); 118 | 119 | // Allow full list to be shown on first focus 120 | ipAddrText.setOnTouchListener( 121 | (v, event) -> { 122 | ipAddrText.showDropDown(); 123 | return false; 124 | }); 125 | ipAddrText.setOnFocusChangeListener( 126 | (v, hasFocus) -> { 127 | if (hasFocus && ipAddrText.getAdapter() != null) { 128 | ipAddrText.showDropDown(); 129 | } 130 | }); 131 | audioPortText.setOnTouchListener( 132 | (v, event) -> { 133 | audioPortText.showDropDown(); 134 | return false; 135 | }); 136 | audioPortText.setOnFocusChangeListener( 137 | (v, hasFocus) -> { 138 | if (hasFocus && ipAddrText.getAdapter() != null) { 139 | audioPortText.showDropDown(); 140 | } 141 | }); 142 | 143 | initBackgroundMusicService(); 144 | } 145 | 146 | @Override 147 | public void onStop() { 148 | super.onStop(); 149 | MediaController.releaseFuture(controllerFuture); 150 | } 151 | 152 | /** 153 | * The two different approaches here is an attempt to support both an old preferences and new 154 | * preferences. The newer version saved to JSON while the old version just saved one string. 155 | */ 156 | static final String IP_PREF = "IP_PREF"; 157 | 158 | static final String PORT_PREF = "PORT_PREF"; 159 | 160 | static final String IP_JSON_PREF = "IP_JSON_PREF"; 161 | static final String PORT_JSON_PREF = "PORT_JSON_PREF"; 162 | 163 | static final String RATE_PREF = "RATE"; 164 | static final String STEREO_PREF = "STEREO"; 165 | static final String BUFFER_MS_PREF = "BUFFER_MS"; 166 | static final String RETRY_PREF = "RETRY"; 167 | static final String USE_PERFORMANCE_MODE_PREF = "USE_PERFORMANCE_MODE"; 168 | static final String USE_MIN_BUFFER_PREF = "USE_MIN_BUFFER"; 169 | 170 | ArrayList getListFromPrefs(SharedPreferences prefs, String keyJson, String keySingle) { 171 | // Retrieve the values from the shared preferences 172 | String jsonString = prefs.getString(keyJson, null); 173 | ArrayList arrayList = new ArrayList<>(); 174 | 175 | if (Strings.isNullOrEmpty(jsonString)) { 176 | // Try to fill with the original key used 177 | String single = prefs.getString(keySingle, null); 178 | if (!Strings.isNullOrEmpty(jsonString)) { 179 | arrayList.add(single); 180 | } 181 | } else { 182 | try { 183 | JSONObject jsonObject = new JSONObject(jsonString); 184 | 185 | // Note that the array is hard-coded as the element labelled 186 | // as 'list' 187 | JSONArray jsonArray = jsonObject.getJSONArray("list"); 188 | for (int i = 0; i < jsonArray.length(); i++) { 189 | String s = (String) jsonArray.get(i); 190 | if (!Strings.isNullOrEmpty(s)) { 191 | arrayList.add(s); 192 | } 193 | } 194 | } catch (JSONException jsonException) { 195 | Log.i(TAG, jsonException.toString()); 196 | } 197 | } 198 | 199 | return arrayList; 200 | } 201 | 202 | private ArrayList getUpdatedArrayList( 203 | SharedPreferences prefs, AutoCompleteTextView view, String keyJson, String keySingle) { 204 | // Retrieve the values from the shared preferences 205 | ArrayList arrayList = getListFromPrefs(prefs, keyJson, keySingle); 206 | 207 | // Make sure the most recent IP is on top 208 | arrayList.remove(view.getText().toString()); 209 | arrayList.add(0, view.getText().toString()); 210 | 211 | if (arrayList.size() >= 4) { 212 | arrayList.subList(4, arrayList.size()).clear(); 213 | } 214 | 215 | return arrayList; 216 | } 217 | 218 | private JSONObject getJson(ArrayList arrayList) { 219 | JSONArray jsonArray = new JSONArray(arrayList); 220 | JSONObject jsonObject = new JSONObject(); 221 | try { 222 | jsonObject.put("list", jsonArray); 223 | } catch (JSONException jsonException) { 224 | Log.i(TAG, jsonException.toString()); 225 | } 226 | 227 | return jsonObject; 228 | } 229 | 230 | private void savePrefs() { 231 | SharedPreferences myPrefs = getSharedPreferences("myPrefs", MODE_PRIVATE); 232 | SharedPreferences.Editor prefsEditor = myPrefs.edit(); 233 | 234 | ipAddrList = getUpdatedArrayList(myPrefs, ipAddrText, IP_JSON_PREF, IP_PREF); 235 | audioPortList = getUpdatedArrayList(myPrefs, audioPortText, PORT_JSON_PREF, PORT_PREF); 236 | 237 | // Write out JSON object 238 | prefsEditor.putString(IP_JSON_PREF, getJson(ipAddrList).toString()); 239 | prefsEditor.putString(PORT_JSON_PREF, getJson(audioPortList).toString()); 240 | 241 | prefsEditor.putBoolean(STEREO_PREF, stereo); 242 | prefsEditor.putInt(RATE_PREF, sampleRate); 243 | prefsEditor.putInt(BUFFER_MS_PREF, bufferMs); 244 | prefsEditor.putBoolean(RETRY_PREF, retry); 245 | prefsEditor.putBoolean(USE_PERFORMANCE_MODE_PREF, usePerformanceMode); 246 | prefsEditor.putBoolean(USE_MIN_BUFFER_PREF, useMinBuffer); 247 | prefsEditor.apply(); 248 | 249 | // Update adapters 250 | ipAddrAdapter.clear(); 251 | ipAddrAdapter.addAll(ipAddrList); 252 | ipAddrAdapter.notifyDataSetChanged(); 253 | audioPortAdapter.clear(); 254 | audioPortAdapter.addAll(audioPortList); 255 | audioPortAdapter.notifyDataSetChanged(); 256 | } 257 | 258 | private static class NoFilterArrayAdapter extends ArrayAdapter { 259 | private final Filter filter = new NoFilter(); 260 | public final List items; 261 | 262 | @Override 263 | @NonNull 264 | public Filter getFilter() { 265 | return filter; 266 | } 267 | 268 | public NoFilterArrayAdapter(Context context, int textViewResourceId, List objects) { 269 | super(context, textViewResourceId, objects); 270 | Log.v(TAG, "Adapter created " + filter); 271 | items = objects; 272 | } 273 | 274 | private class NoFilter extends Filter { 275 | 276 | @Override 277 | protected Filter.FilterResults performFiltering(CharSequence arg0) { 278 | Filter.FilterResults result = new Filter.FilterResults(); 279 | result.values = items; 280 | result.count = items.size(); 281 | return result; 282 | } 283 | 284 | @Override 285 | protected void publishResults(CharSequence arg0, Filter.FilterResults arg1) { 286 | notifyDataSetChanged(); 287 | } 288 | } 289 | } 290 | 291 | @Override 292 | public void onResume() { 293 | super.onResume(); 294 | SharedPreferences myPrefs = getSharedPreferences("myPrefs", MODE_PRIVATE); 295 | 296 | ipAddrList = getListFromPrefs(myPrefs, IP_JSON_PREF, IP_PREF); 297 | ipAddrAdapter = 298 | new NoFilterArrayAdapter<>(this, android.R.layout.simple_list_item_1, ipAddrList); 299 | ipAddrText.setAdapter(ipAddrAdapter); 300 | ipAddrText.setThreshold(1); 301 | if (!ipAddrList.isEmpty()) { 302 | ipAddrText.setText(ipAddrList.get(0)); 303 | } 304 | 305 | if (!isEmpty(ipAddrText)) { 306 | getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); 307 | } 308 | 309 | audioPortList = getListFromPrefs(myPrefs, PORT_JSON_PREF, PORT_PREF); 310 | audioPortAdapter = 311 | new NoFilterArrayAdapter<>(this, android.R.layout.simple_list_item_1, audioPortList); 312 | audioPortText.setAdapter(audioPortAdapter); 313 | audioPortText.setThreshold(1); 314 | if (!audioPortList.isEmpty()) { 315 | audioPortText.setText(audioPortList.get(0)); 316 | } 317 | 318 | // These hard-coded values should match the defaults in the strings array 319 | Resources res = getResources(); 320 | 321 | sampleRate = myPrefs.getInt(RATE_PREF, MusicService.DEFAULT_SAMPLE_RATE); 322 | String rateString = Integer.toString(sampleRate); 323 | String[] sampleRateStrings = res.getStringArray(R.array.sampleRates); 324 | for (int i = 0; i < sampleRateStrings.length; i++) { 325 | if (sampleRateStrings[i].contains(rateString)) { 326 | Spinner sampleRateSpinner = findViewById(R.id.spinnerSampleRate); 327 | sampleRateSpinner.setSelection(i); 328 | break; 329 | } 330 | } 331 | 332 | stereo = myPrefs.getBoolean(STEREO_PREF, MusicService.DEFAULT_STEREO); 333 | String[] stereoStrings = res.getStringArray(R.array.stereo); 334 | Spinner stereoSpinner = findViewById(R.id.stereo); 335 | String stereoKey = getResources().getString(R.string.stereoKey); 336 | if (stereoStrings[0].contains(stereoKey) == stereo) { 337 | stereoSpinner.setSelection(0); 338 | } else { 339 | stereoSpinner.setSelection(1); 340 | } 341 | 342 | bufferMs = myPrefs.getInt(BUFFER_MS_PREF, MusicService.DEFAULT_BUFFER_MS); 343 | Log.d(TAG, "bufferMs:" + bufferMs); 344 | EditText e = findViewById(R.id.editTextBufferSize); 345 | e.setText(String.format(Locale.getDefault(), "%d", bufferMs)); 346 | 347 | retry = myPrefs.getBoolean(RETRY_PREF, MusicService.DEFAULT_RETRY); 348 | ((CheckBox) findViewById(R.id.checkBoxRetry)).setChecked(retry); 349 | Log.d(TAG, "retry:" + retry); 350 | 351 | usePerformanceMode = 352 | myPrefs.getBoolean(USE_PERFORMANCE_MODE_PREF, MusicService.DEFAULT_USE_PERFORMANCE_MODE); 353 | ((CheckBox) findViewById(R.id.checkBoxUsePerformanceMode)).setChecked(usePerformanceMode); 354 | Log.d(TAG, "usePerformanceMode:" + usePerformanceMode); 355 | 356 | useMinBuffer = myPrefs.getBoolean(USE_MIN_BUFFER_PREF, MusicService.DEFAULT_USE_MIN_BUFFER); 357 | ((CheckBox) findViewById(R.id.checkBoxUseMinBuffer)).setChecked(useMinBuffer); 358 | Log.d(TAG, "useMinBuffer:" + useMinBuffer); 359 | } 360 | 361 | @Override 362 | public boolean onCreateOptionsMenu(Menu menu) { 363 | // Inflate the menu items for use in the action bar 364 | MenuInflater inflater = getMenuInflater(); 365 | inflater.inflate(R.menu.actions, menu); 366 | return super.onCreateOptionsMenu(menu); 367 | } 368 | 369 | public boolean onOptionsItemSelected(MenuItem item) { 370 | if (item.getItemId() == R.id.notice_item) { 371 | Intent intent = new Intent(this, NoticeActivity.class); 372 | startActivity(intent); 373 | return true; 374 | } else { 375 | return super.onOptionsItemSelected(item); 376 | } 377 | } 378 | 379 | public void onClick(View target) { 380 | boolean useMedia3 = ((CheckBox) findViewById(R.id.checkBoxUseMedia3)).isChecked(); 381 | 382 | // Send the correct intent to the MusicService, according to the 383 | // button that was clicked 384 | if (target == playButton) { 385 | switch (getNetworkConnection()) { 386 | case NOT_CONNECTED: 387 | Toast.makeText(getApplicationContext(), "No network connectivity.", Toast.LENGTH_SHORT) 388 | .show(); 389 | return; 390 | case NON_WIFI_CONNECTED: 391 | Toast.makeText( 392 | getApplicationContext(), "WARNING: wifi not connected.", Toast.LENGTH_SHORT) 393 | .show(); 394 | break; 395 | default: 396 | break; 397 | } 398 | 399 | hideKb(); 400 | 401 | // Get the IP address and port and put it in the intent 402 | Bundle bundle = new Bundle(); 403 | String ipAddr = ipAddrText.getText().toString(); 404 | String portStr = audioPortText.getText().toString(); 405 | 406 | // Check address string against domain, IPv4, and IPv6 407 | DomainValidator domainValidator = DomainValidator.getInstance(); 408 | InetAddressValidator inetAddressValidator = InetAddressValidator.getInstance(); 409 | if (!domainValidator.isValid(ipAddr) 410 | && !inetAddressValidator.isValidInet4Address(ipAddr) 411 | && !inetAddressValidator.isValidInet6Address(ipAddr)) { 412 | Toast.makeText(getApplicationContext(), "Invalid address", Toast.LENGTH_SHORT).show(); 413 | return; 414 | } 415 | Log.d(TAG, "ip:" + ipAddr); 416 | bundle.putString(MusicService.DATA_IP_ADDRESS, ipAddr); 417 | 418 | int audioPort; 419 | try { 420 | audioPort = Integer.parseInt(portStr); 421 | } catch (NumberFormatException nfe) { 422 | Log.e(TAG, "Invalid port:" + nfe); 423 | Toast.makeText(getApplicationContext(), "Invalid port", Toast.LENGTH_SHORT).show(); 424 | return; 425 | } 426 | Log.d(TAG, "port:" + audioPort); 427 | bundle.putInt(MusicService.DATA_AUDIO_PORT, audioPort); 428 | 429 | // Extract sample rate 430 | Spinner sampleRateSpinner = findViewById(R.id.spinnerSampleRate); 431 | String rateStr = String.valueOf(sampleRateSpinner.getSelectedItem()); 432 | String[] rateSplit = rateStr.split(" "); 433 | try { 434 | sampleRate = Integer.parseInt(rateSplit[0]); 435 | Log.i(TAG, "rate:" + sampleRate); 436 | bundle.putInt(MusicService.DATA_SAMPLE_RATE, sampleRate); 437 | } catch (NumberFormatException nfe) { 438 | Log.e(TAG, "Invalid rate:" + nfe); 439 | Toast.makeText(getApplicationContext(), "Invalid rate", Toast.LENGTH_SHORT).show(); 440 | return; 441 | } 442 | 443 | // Extract stereo/mono setting 444 | Spinner stereoSpinner = findViewById(R.id.stereo); 445 | String stereoSettingString = String.valueOf(stereoSpinner.getSelectedItem()); 446 | String stereoKey = getResources().getString(R.string.stereoKey); 447 | stereo = stereoSettingString.contains(stereoKey); 448 | bundle.putBoolean(MusicService.DATA_STEREO, stereo); 449 | Log.i(TAG, "stereo:" + stereo); 450 | 451 | // Get the latest buffer entry 452 | EditText e = findViewById(R.id.editTextBufferSize); 453 | String bufferMsString = e.getText().toString(); 454 | try { 455 | bufferMs = Integer.parseInt(bufferMsString); 456 | Log.d(TAG, "buffer ms:" + bufferMs); 457 | bundle.putInt(MusicService.DATA_BUFFER_MS, bufferMs); 458 | } catch (NumberFormatException nfe) { 459 | Log.e(TAG, "Invalid buffer ms:" + nfe); 460 | Toast.makeText(getApplicationContext(), "Invalid buffer ms", Toast.LENGTH_SHORT).show(); 461 | return; 462 | } 463 | 464 | // Get the retry checkbox 465 | retry = ((CheckBox) findViewById(R.id.checkBoxRetry)).isChecked(); 466 | Log.d(TAG, "retry:" + retry); 467 | bundle.putBoolean(MusicService.DATA_RETRY, retry); 468 | 469 | // Get the usePerformanceMode checkbox 470 | usePerformanceMode = ((CheckBox) findViewById(R.id.checkBoxUsePerformanceMode)).isChecked(); 471 | Log.d(TAG, "usePerformanceMode:" + usePerformanceMode); 472 | bundle.putBoolean(MusicService.DATA_USE_PERFORMANCE_MODE, usePerformanceMode); 473 | 474 | // Get the useMinBuffer checkbox 475 | useMinBuffer = ((CheckBox) findViewById(R.id.checkBoxUseMinBuffer)).isChecked(); 476 | Log.d(TAG, "useMinBuffer:" + useMinBuffer); 477 | bundle.putBoolean(MusicService.DATA_USE_MIN_BUFFER, useMinBuffer); 478 | 479 | // Save current settings 480 | savePrefs(); 481 | startMusicService(useMedia3, bundle); 482 | } else if (target == stopButton) { 483 | hideKb(); 484 | stopMusicService(useMedia3); 485 | } 486 | } 487 | 488 | private void hideKb() { 489 | InputMethodManager inputManager = 490 | (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); 491 | 492 | View v = getCurrentFocus(); 493 | if (v != null) { 494 | inputManager.hideSoftInputFromWindow(v.getWindowToken(), InputMethodManager.HIDE_NOT_ALWAYS); 495 | } 496 | } 497 | 498 | private boolean isEmpty(EditText etText) { 499 | return Strings.isNullOrEmpty(etText.getText().toString().trim()); 500 | } 501 | 502 | private NetworkConnection getNetworkConnection() { 503 | ConnectivityManager connectivityManager = 504 | (ConnectivityManager) getSystemService(Context.CONNECTIVITY_SERVICE); 505 | if (connectivityManager == null) { 506 | return NetworkConnection.NOT_CONNECTED; 507 | } 508 | 509 | NetworkCapabilities capabilities = 510 | connectivityManager.getNetworkCapabilities(connectivityManager.getActiveNetwork()); 511 | if (capabilities == null) { 512 | return NetworkConnection.NOT_CONNECTED; 513 | } 514 | if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { 515 | return NetworkConnection.NOT_CONNECTED; 516 | } 517 | if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { 518 | return NetworkConnection.WIFI_CONNECTED; 519 | } else { 520 | return NetworkConnection.NON_WIFI_CONNECTED; 521 | } 522 | } 523 | 524 | private void startMusicService(boolean useMedia3, Bundle bundle) { 525 | if (useMedia3) { 526 | try { 527 | MediaItem mediaItem = 528 | new MediaItem.Builder() 529 | .setUri( 530 | new Uri.Builder() 531 | .encodedAuthority( 532 | bundle.getString(MusicService.DATA_IP_ADDRESS) 533 | + ":" 534 | + bundle.getInt(MusicService.DATA_AUDIO_PORT)) 535 | .build()) 536 | .setMediaMetadata( 537 | new MediaMetadata.Builder() 538 | .setTitle( 539 | "Streaming from " + bundle.getString(MusicService.DATA_IP_ADDRESS)) 540 | .setArtist("Simple Protocol Player") 541 | .setExtras(bundle) 542 | .build()) 543 | .build(); 544 | controllerFuture.get().setMediaItem(mediaItem); 545 | controllerFuture.get().prepare(); 546 | controllerFuture.get().play(); 547 | } catch (Exception e) { 548 | Log.e(TAG, "startMusicService:media3 exception", e); 549 | } 550 | } else { 551 | Intent i = new Intent(MusicService.ACTION_PLAY); 552 | i.setPackage(getPackageName()).putExtras(bundle); 553 | startService(i); 554 | } 555 | } 556 | 557 | private void stopMusicService(boolean useMedia3) { 558 | if (useMedia3) { 559 | try { 560 | controllerFuture.get().stop(); 561 | } catch (Exception e) { 562 | Log.e(TAG, "stopMusicService:media3 exception", e); 563 | } 564 | } else { 565 | Intent i = new Intent(MusicService.ACTION_STOP); 566 | i.setPackage(getPackageName()); 567 | startService(i); 568 | } 569 | } 570 | 571 | ListenableFuture controllerFuture; 572 | 573 | private void initBackgroundMusicService() { 574 | SessionToken sessionToken = 575 | new SessionToken(this, new ComponentName(this, BackgroundMusicService.class)); 576 | controllerFuture = new MediaController.Builder(this, sessionToken).buildAsync(); 577 | controllerFuture.addListener( 578 | () -> { 579 | // MediaController is available here with controllerFuture.get() 580 | }, 581 | MoreExecutors.directExecutor()); 582 | } 583 | } 584 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/MusicFocusable.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * Copyright (C) 2014 kaytat 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package com.kaytat.simpleprotocolplayer; 19 | 20 | /** Represents something that can react to audio focus events. */ 21 | public interface MusicFocusable { 22 | /** Signals that audio focus was gained. */ 23 | void onGainedAudioFocus(); 24 | 25 | /** 26 | * Signals that audio focus was lost. 27 | * 28 | * @param canDuck If true, audio can continue in "ducked" mode (low volume). Otherwise, all audio 29 | * must stop. 30 | */ 31 | void onLostAudioFocus(boolean canDuck); 32 | } 33 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/MusicIntentReceiver.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * Copyright (C) 2014 kaytat 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package com.kaytat.simpleprotocolplayer; 19 | 20 | import android.content.BroadcastReceiver; 21 | import android.content.Context; 22 | import android.content.Intent; 23 | import android.util.Log; 24 | import android.view.KeyEvent; 25 | 26 | /** 27 | * Receives broadcast intents. In particular, we are interested in the 28 | * android.media.AUDIO_BECOMING_NOISY and android.intent.action.MEDIA_BUTTON 29 | * intents, which is broadcast, for example, when the user disconnects the 30 | * headphones. This class works because we are declaring it in a < 31 | * receiver> tag in AndroidManifest.xml. 32 | */ 33 | public class MusicIntentReceiver extends BroadcastReceiver { 34 | static final String TAG = "MusicIntentReceiver"; 35 | 36 | @Override 37 | public void onReceive(Context context, Intent intent) { 38 | if (intent == null || intent.getAction() == null) { 39 | Log.e(TAG, "Intent action is null"); 40 | return; 41 | } 42 | if (intent.getAction() 43 | .equals(android.media.AudioManager.ACTION_AUDIO_BECOMING_NOISY)) { 44 | Log.i(TAG, "onReceive - headphones disconnected. Stopping"); 45 | // send an intent to our MusicService to telling it to pause the 46 | // audio 47 | context.startService(new Intent(MusicService.ACTION_STOP)); 48 | } else if (intent.getAction().equals(Intent.ACTION_MEDIA_BUTTON) && 49 | intent.getExtras() != null && 50 | intent.getExtras().get(Intent.EXTRA_KEY_EVENT) != null) { 51 | KeyEvent keyEvent = 52 | (KeyEvent) intent.getExtras().get(Intent.EXTRA_KEY_EVENT); 53 | if (keyEvent == null || keyEvent.getAction() != KeyEvent.ACTION_DOWN) { 54 | return; 55 | } 56 | 57 | if (keyEvent.getKeyCode() == KeyEvent.KEYCODE_MEDIA_STOP) { 58 | Log.i(TAG, "onReceive - media button stop"); 59 | context.startService(new Intent(MusicService.ACTION_STOP)); 60 | } 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/MusicService.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * Copyright (C) 2014 kaytat 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package com.kaytat.simpleprotocolplayer; 19 | 20 | import android.app.Notification; 21 | import android.app.NotificationChannel; 22 | import android.app.NotificationManager; 23 | import android.app.PendingIntent; 24 | import android.app.Service; 25 | import android.content.Intent; 26 | import android.os.IBinder; 27 | import android.util.Log; 28 | import androidx.core.app.NotificationCompat; 29 | import java.util.ArrayList; 30 | 31 | /** 32 | * Service that handles media playback. This is the Service through which we perform all the media 33 | * handling in our application. 34 | */ 35 | public class MusicService extends Service 36 | implements MusicFocusable, WorkerThreadPair.StopPlaybackCallback { 37 | 38 | // The tag we put on debug messages 39 | static final String TAG = "SimpleProtocol"; 40 | 41 | static final int DEFAULT_AUDIO_PORT = 12345; 42 | static final int DEFAULT_SAMPLE_RATE = 44100; 43 | static final boolean DEFAULT_STEREO = true; 44 | static final int DEFAULT_BUFFER_MS = 50; 45 | static final boolean DEFAULT_RETRY = false; 46 | static final boolean DEFAULT_USE_PERFORMANCE_MODE = false; 47 | static final boolean DEFAULT_USE_MIN_BUFFER = false; 48 | 49 | // These are the Intent actions that we are prepared to handle. Notice 50 | // that the fact these constants exist in our class is a mere 51 | // convenience: what really defines the actions our service can handle 52 | // are the tags in the tag for our service in 53 | // AndroidManifest.xml. 54 | public static final String ACTION_PLAY = "com.kaytat.simpleprotocolplayer.action.PLAY"; 55 | public static final String ACTION_STOP = "com.kaytat.simpleprotocolplayer.action.STOP"; 56 | 57 | public static final String DATA_IP_ADDRESS = "ip_addr"; 58 | public static final String DATA_AUDIO_PORT = "audio_port"; 59 | public static final String DATA_SAMPLE_RATE = "sample_rate"; 60 | public static final String DATA_STEREO = "stereo"; 61 | public static final String DATA_BUFFER_MS = "buffer_ms"; 62 | public static final String DATA_RETRY = "retry"; 63 | public static final String DATA_USE_PERFORMANCE_MODE = "use_performance_mode"; 64 | public static final String DATA_USE_MIN_BUFFER = "use_min_buffer"; 65 | 66 | // The volume we set the media player to when we lose audio focus, but 67 | // are allowed to reduce the volume instead of stopping playback. 68 | public static final float DUCK_VOLUME = 0.1f; 69 | 70 | private final ArrayList workers = new ArrayList<>(); 71 | 72 | // AudioFocusHelper object 73 | AudioFocusHelper mAudioFocusHelper = null; 74 | 75 | // indicates the state our service: 76 | enum State { 77 | Stopped, // media player is stopped and not prepared to play 78 | Playing // playback active (media player ready!) 79 | } 80 | 81 | State mState = State.Stopped; 82 | 83 | // do we have audio focus? 84 | enum AudioFocus { 85 | NoFocusNoDuck, // we don't have audio focus, and can't duck 86 | NoFocusCanDuck, // we don't have focus, but can play at a low 87 | // volume ("ducking") 88 | Focused // we have full audio focus 89 | } 90 | 91 | AudioFocus mAudioFocus = AudioFocus.NoFocusNoDuck; 92 | 93 | // Wifi lock that we hold when streaming files from the internet, in 94 | // order to prevent the device from shutting off the Wifi radio 95 | WifiLockManager wifiLockManager; 96 | 97 | // The ID we use for the notification (the onscreen alert that appears at 98 | // the notification area at the top of the screen as an icon -- and as 99 | // text as well if the user expands the notification area). 100 | final int NOTIFICATION_ID = 1; 101 | final String NOTIFICATION_CHANNEL_ID = "SimpleProtocolPlayer"; 102 | 103 | Notification mNotification = null; 104 | 105 | @Override 106 | public void onCreate() { 107 | Log.i(TAG, "Creating service"); 108 | 109 | // Create the Wifi lock (this does not acquire the lock, this just 110 | // creates it) 111 | wifiLockManager = new WifiLockManager(this); 112 | wifiLockManager.setEnabled(true); 113 | 114 | mAudioFocusHelper = new AudioFocusHelper(getApplicationContext(), this); 115 | } 116 | 117 | /** 118 | * Called when we receive an Intent. When we receive an intent sent to us via startService(), this 119 | * is the method that gets called. So here we react appropriately depending on the Intent's 120 | * action, which specifies what is being requested of us. 121 | */ 122 | @Override 123 | public int onStartCommand(Intent intent, int flags, int startId) { 124 | String action = intent.getAction(); 125 | if (action != null) { 126 | if (action.equals(ACTION_PLAY)) { 127 | processPlayRequest(intent); 128 | } else if (action.equals(ACTION_STOP)) { 129 | processStopRequest(); 130 | } 131 | } 132 | 133 | // Means we started the service, but don't want it to restart in case it's 134 | // killed. 135 | return START_NOT_STICKY; 136 | } 137 | 138 | void processPlayRequest(Intent i) { 139 | if (mState == State.Stopped) { 140 | tryToGetAudioFocus(); 141 | } else { 142 | stopWorkers(); 143 | } 144 | 145 | playStream( 146 | i.getStringExtra(DATA_IP_ADDRESS), 147 | i.getIntExtra(DATA_AUDIO_PORT, DEFAULT_AUDIO_PORT), 148 | i.getIntExtra(DATA_SAMPLE_RATE, DEFAULT_SAMPLE_RATE), 149 | i.getBooleanExtra(DATA_STEREO, DEFAULT_STEREO), 150 | i.getIntExtra(DATA_BUFFER_MS, DEFAULT_BUFFER_MS), 151 | i.getBooleanExtra(DATA_RETRY, DEFAULT_RETRY), 152 | i.getBooleanExtra(DATA_USE_PERFORMANCE_MODE, DEFAULT_USE_PERFORMANCE_MODE), 153 | i.getBooleanExtra(DATA_USE_MIN_BUFFER, DEFAULT_USE_MIN_BUFFER)); 154 | } 155 | 156 | void processStopRequest() { 157 | if (mState == State.Playing) { 158 | mState = State.Stopped; 159 | 160 | // let go of all resources... 161 | relaxResources(); 162 | giveUpAudioFocus(); 163 | 164 | // service is no longer necessary. Will be started again if needed. 165 | stopSelf(); 166 | } 167 | } 168 | 169 | /** 170 | * Releases resources used by the service for playback. This includes the "foreground service" 171 | * status and notification, the wake locks and the AudioTrack 172 | */ 173 | void relaxResources() { 174 | // stop being a foreground service 175 | stopForeground(true); 176 | 177 | // we can also release the Wifi lock, if we're holding it 178 | wifiLockManager.setStayAwake(false); 179 | 180 | // Wait for worker thread to stop if running 181 | stopWorkers(); 182 | } 183 | 184 | void stopWorkers() { 185 | for (WorkerThreadPair worker : workers) { 186 | worker.stopAndInterrupt(); 187 | } 188 | 189 | workers.clear(); 190 | } 191 | 192 | void tryToGetAudioFocus() { 193 | if (mAudioFocus != AudioFocus.Focused 194 | && mAudioFocusHelper != null 195 | && mAudioFocusHelper.requestFocus()) { 196 | mAudioFocus = AudioFocus.Focused; 197 | } 198 | } 199 | 200 | void giveUpAudioFocus() { 201 | if (mAudioFocus == AudioFocus.Focused 202 | && mAudioFocusHelper != null 203 | && mAudioFocusHelper.abandonFocus()) { 204 | mAudioFocus = AudioFocus.NoFocusNoDuck; 205 | } 206 | } 207 | 208 | /** Reconfigures AudioTrack according to audio focus settings and starts/restarts it. */ 209 | void configVolume() { 210 | if (mAudioFocus == AudioFocus.NoFocusNoDuck) { 211 | // If we don't have audio focus and can't duck, we have to pause, 212 | // even if mState is State.Playing. But we stay in the Playing 213 | // state so that we know we have to resume playback once we get 214 | // the focus back. 215 | if (mState == State.Playing) { 216 | processStopRequest(); 217 | } 218 | 219 | return; 220 | } 221 | 222 | for (WorkerThreadPair it : workers) { 223 | if (mAudioFocus == AudioFocus.NoFocusCanDuck) { 224 | it.audioTrack.setVolume(DUCK_VOLUME); // we'll be 225 | // relatively 226 | // quiet 227 | } else { 228 | it.audioTrack.setVolume(1.0f); // we can be loud 229 | } 230 | } 231 | } 232 | 233 | /** Play the stream using the given IP address and port */ 234 | void playStream( 235 | String serverAddr, 236 | int serverPort, 237 | int sample_rate, 238 | boolean stereo, 239 | int buffer_ms, 240 | boolean retry, 241 | boolean usePerformanceMode, 242 | boolean useMinBuffer) { 243 | 244 | mState = State.Stopped; 245 | relaxResources(); 246 | 247 | workers.add( 248 | new WorkerThreadPair( 249 | this, 250 | this, 251 | serverAddr, 252 | serverPort, 253 | sample_rate, 254 | stereo, 255 | buffer_ms, 256 | retry, 257 | usePerformanceMode, 258 | useMinBuffer)); 259 | 260 | wifiLockManager.setStayAwake(true); 261 | 262 | mState = State.Playing; 263 | configVolume(); 264 | 265 | setUpAsForeground("Streaming from " + serverAddr); 266 | } 267 | 268 | /** 269 | * Configures service as a foreground service. A foreground service is a service that's doing 270 | * something the user is actively aware of (such as playing music), and must appear to the user as 271 | * a notification. That's why we create the notification here. 272 | */ 273 | void setUpAsForeground(String text) { 274 | createNotificationChannel(); 275 | 276 | PendingIntent pi = 277 | PendingIntent.getActivity( 278 | getApplicationContext(), 279 | 0, 280 | new Intent(getApplicationContext(), MainActivity.class), 281 | PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE); 282 | 283 | mNotification = 284 | new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) 285 | .setSmallIcon(R.drawable.ic_stat_playing) 286 | .setOngoing(true) 287 | .setContentTitle(NOTIFICATION_CHANNEL_ID) 288 | .setContentText(text) 289 | .setContentIntent(pi) 290 | .build(); 291 | startForeground(NOTIFICATION_ID, mNotification); 292 | } 293 | 294 | private void createNotificationChannel() { 295 | int importance = NotificationManager.IMPORTANCE_DEFAULT; 296 | NotificationChannel channel = 297 | new NotificationChannel(NOTIFICATION_CHANNEL_ID, NOTIFICATION_CHANNEL_ID, importance); 298 | channel.setSound(null, null); 299 | // Register the channel with the system; you can't change the 300 | // importance or other notification behaviors after this 301 | NotificationManager notificationManager = getSystemService(NotificationManager.class); 302 | notificationManager.createNotificationChannel(channel); 303 | } 304 | 305 | @Override 306 | public void onGainedAudioFocus() { 307 | Log.i(TAG, "Gained audio focus"); 308 | mAudioFocus = AudioFocus.Focused; 309 | 310 | // restart media player with new focus settings 311 | if (mState == State.Playing) { 312 | configVolume(); 313 | } 314 | } 315 | 316 | @Override 317 | public void onLostAudioFocus(boolean canDuck) { 318 | Log.i(TAG, "Lost audio focus: canDuck:" + canDuck); 319 | mAudioFocus = canDuck ? AudioFocus.NoFocusCanDuck : AudioFocus.NoFocusNoDuck; 320 | 321 | // start/restart/pause media player with new focus settings 322 | if (mState == State.Playing) { 323 | configVolume(); 324 | } 325 | } 326 | 327 | @Override 328 | public void onDestroy() { 329 | // Service is being killed, so make sure we release our resources 330 | mState = State.Stopped; 331 | relaxResources(); 332 | giveUpAudioFocus(); 333 | } 334 | 335 | @Override 336 | public IBinder onBind(Intent arg0) { 337 | return null; 338 | } 339 | 340 | public void stopPlayback() { 341 | processStopRequest(); 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/NetworkReadThread.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * Copyright (C) 2014 kaytat 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package com.kaytat.simpleprotocolplayer; 19 | 20 | import android.util.Log; 21 | import java.io.DataInputStream; 22 | import java.io.IOException; 23 | import java.net.Socket; 24 | 25 | /** 26 | * Worker thread reads data from the network 27 | */ 28 | class NetworkReadThread extends ThreadStoppable { 29 | final String TAG; 30 | 31 | static final int[][] RETRY_PARAMS = new int[][]{{5, 12}, {20, 6}, {60, 2}}; 32 | 33 | final WorkerThreadPair syncObject; 34 | final String ipAddr; 35 | final int port; 36 | final boolean attemptConnectionRetry; 37 | final byte[][] dataBuffer; 38 | final int numBuffers; 39 | int bufferIndex; 40 | 41 | // socket timeout at 5 seconds 42 | static final int SOCKET_TIMEOUT = 5 * 1000; 43 | 44 | public NetworkReadThread(WorkerThreadPair syncObject, String ipAddr, int port, 45 | boolean attemptConnectionRetry, String debugTag) { 46 | this.TAG = debugTag; 47 | this.setName(debugTag); 48 | this.syncObject = syncObject; 49 | this.ipAddr = ipAddr; 50 | this.port = port; 51 | this.attemptConnectionRetry = attemptConnectionRetry; 52 | 53 | // since we use BlockingQueue to pass data 54 | // so at most we will use NUM_PACKETS (in queue) + 1 (taken by 55 | // audioThread) +1 (read socket) 56 | // buffers . 57 | numBuffers = WorkerThreadPair.NUM_PACKETS + 2; 58 | bufferIndex = 0; 59 | dataBuffer = new byte[numBuffers][]; 60 | for (int i = 0; i < numBuffers; i++) { 61 | dataBuffer[i] = new byte[syncObject.bytesPerAudioPacket]; 62 | } 63 | } 64 | 65 | @Override 66 | public void run() { 67 | Log.i(TAG, "start"); 68 | boolean connectionMade; 69 | int retryCount = 0; 70 | int retryParamIndex = 0; 71 | 72 | while (running) { 73 | connectionMade = runImpl(); 74 | 75 | if (!running) { 76 | Log.i(TAG, "not running"); 77 | break; 78 | } 79 | if (!attemptConnectionRetry) { 80 | Log.i(TAG, "no retries"); 81 | break; 82 | } 83 | 84 | if (connectionMade) { 85 | retryCount = retryParamIndex = 0; 86 | continue; 87 | } 88 | 89 | // There was connection made. Increment the counters. 90 | if (retryCount >= RETRY_PARAMS[retryParamIndex][1]) { 91 | retryCount = 0; 92 | retryParamIndex++; 93 | if (retryParamIndex >= RETRY_PARAMS.length) { 94 | // Hit the limit. Exit. 95 | Log.i(TAG, "retry limit reached"); 96 | break; 97 | } 98 | } 99 | 100 | Log.d(TAG, 101 | "retryCount:" + retryCount + " retryParamIndex:" + retryParamIndex); 102 | 103 | try { 104 | //noinspection BusyWait 105 | Thread.sleep((long) RETRY_PARAMS[retryParamIndex][0] * 1000); 106 | } catch (Exception e) { 107 | // Ignore. 108 | } 109 | retryCount++; 110 | } 111 | 112 | // Determine if cleanup is necessary 113 | if (running) { 114 | syncObject.brokenShutdown(); 115 | } 116 | Log.i(TAG, "done"); 117 | } 118 | 119 | public boolean runImpl() { 120 | Socket socket = null; 121 | boolean connectionMade = false; 122 | 123 | try { 124 | // Create the TCP socket and setup some parameters 125 | socket = new Socket(ipAddr, port); 126 | DataInputStream is = new DataInputStream(socket.getInputStream()); 127 | socket.setSoTimeout(SOCKET_TIMEOUT); 128 | socket.setTcpNoDelay(true); 129 | 130 | Log.i(TAG, "running"); 131 | 132 | while (running) { 133 | // Get a buffer of audio data 134 | is.readFully(dataBuffer[bufferIndex]); 135 | connectionMade = true; 136 | 137 | boolean dataPassed = 138 | syncObject.dataQueue.offer(dataBuffer[bufferIndex]); 139 | 140 | if (!dataPassed) { 141 | // if current buffer not used to queue, 142 | // will be used as next read buffer, should not update 143 | // Filled up. Throw away everything that's in the network 144 | // queue. 145 | Log.w(TAG, "drop " + syncObject.bytesPerAudioPacket + " bytes"); 146 | continue; 147 | } 148 | bufferIndex = (bufferIndex + 1) % numBuffers; 149 | } 150 | } catch (Exception e) { 151 | Log.i(TAG, "runImpl:exception:" + e); 152 | 153 | // Attempt to release resources 154 | if (socket != null) { 155 | try { 156 | socket.close(); 157 | } catch (IOException iex) { 158 | Log.i(TAG, "exception while closing socket:" + iex); 159 | } 160 | } 161 | } 162 | 163 | return connectionMade; 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/NoticeActivity.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2007 The Android Open Source Project 3 | * Copyright (C) 2014 kaytat 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package com.kaytat.simpleprotocolplayer; 19 | 20 | import android.os.Bundle; 21 | import android.util.Log; 22 | import android.widget.TextView; 23 | import androidx.appcompat.app.AppCompatActivity; 24 | import java.io.IOException; 25 | import java.io.InputStream; 26 | 27 | public class NoticeActivity extends AppCompatActivity { 28 | static final String TAG = "NoticeActivity"; 29 | 30 | /** 31 | * Initialization of the Activity after it is first created. Must at least 32 | * call {@link android.app.Activity#setContentView setContentView()} to 33 | * describe what is to be displayed in the screen. 34 | */ 35 | @Override 36 | protected void onCreate(Bundle savedInstanceState) { 37 | super.onCreate(savedInstanceState); 38 | 39 | // See assets/res/any/layout/styled_text.xml for this 40 | // view layout definition. 41 | setContentView(R.layout.notice); 42 | 43 | // Programmatically load text from an asset and place it into the 44 | // text view. Note that the text we are loading is ASCII, so we 45 | // need to convert it to UTF-16. 46 | try { 47 | InputStream is = getAssets().open("notice.txt"); 48 | 49 | // We guarantee that the available method returns the total 50 | // size of the asset... of course, this does mean that a single 51 | // asset can't be more than 2 gigs. 52 | int size = is.available(); 53 | 54 | // Read the entire asset into a local byte buffer. 55 | byte[] buffer = new byte[size]; 56 | int readBytes = is.read(buffer); 57 | is.close(); 58 | 59 | // Finally stick the string into the text view. 60 | TextView tv = findViewById(R.id.notice_view); 61 | if (readBytes != 0) { 62 | tv.setText(new String(buffer)); 63 | } else { 64 | tv.setText(R.string.error); 65 | } 66 | } catch (IOException e) { 67 | // Should never happen! 68 | Log.e(TAG, "exception:" + e); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/SppPlayer.java: -------------------------------------------------------------------------------- 1 | package com.kaytat.simpleprotocolplayer; 2 | 3 | import android.content.Context; 4 | import android.os.Bundle; 5 | import androidx.annotation.NonNull; 6 | import androidx.annotation.Nullable; 7 | import androidx.media3.common.MediaItem; 8 | import androidx.media3.common.SimpleBasePlayer; 9 | import androidx.media3.common.util.Log; 10 | import androidx.media3.common.util.UnstableApi; 11 | import com.google.common.collect.ImmutableList; 12 | import com.google.common.util.concurrent.Futures; 13 | import com.google.common.util.concurrent.ListenableFuture; 14 | import java.util.List; 15 | 16 | @UnstableApi 17 | public class SppPlayer extends SimpleBasePlayer implements WorkerThreadPair.StopPlaybackCallback { 18 | private static final String TAG = "SppPlayer"; 19 | 20 | private final WifiLockManager wifiLockManager; 21 | private final Context context; 22 | 23 | @Nullable private WorkerThreadPair workers; 24 | 25 | private State state = 26 | new State.Builder() 27 | .setAvailableCommands( 28 | new Commands.Builder() 29 | .add(COMMAND_GET_METADATA) 30 | .add(COMMAND_GET_CURRENT_MEDIA_ITEM) 31 | .add(COMMAND_PLAY_PAUSE) 32 | .add(COMMAND_PREPARE) 33 | .add(COMMAND_RELEASE) 34 | .add(COMMAND_SET_MEDIA_ITEM) 35 | .add(COMMAND_STOP) 36 | .build()) 37 | .setPlaybackState(STATE_IDLE) 38 | .build(); 39 | 40 | protected SppPlayer(Context context) { 41 | super(context.getMainLooper()); 42 | this.context = context; 43 | wifiLockManager = new WifiLockManager(context); 44 | wifiLockManager.setEnabled(true); 45 | } 46 | 47 | @NonNull 48 | @Override 49 | protected State getState() { 50 | Log.d(TAG, "getState"); 51 | return state; 52 | } 53 | 54 | @NonNull 55 | @Override 56 | protected ListenableFuture handlePrepare() { 57 | Log.d(TAG, "handlePrepare"); 58 | state = state.buildUpon().setPlaybackState(STATE_READY).build(); 59 | startStream(); 60 | return Futures.immediateVoidFuture(); 61 | } 62 | 63 | @NonNull 64 | @Override 65 | protected ListenableFuture handleSetPlayWhenReady(boolean playWhenReady) { 66 | Log.d(TAG, "handleSetPlayWhenReady:" + playWhenReady); 67 | state = 68 | state 69 | .buildUpon() 70 | .setPlayWhenReady(playWhenReady, PLAY_WHEN_READY_CHANGE_REASON_USER_REQUEST) 71 | .build(); 72 | startStream(); 73 | return Futures.immediateVoidFuture(); 74 | } 75 | 76 | @NonNull 77 | @Override 78 | protected ListenableFuture handleSetMediaItems( 79 | List mediaItems, int startIndex, long startPositionMs) { 80 | Log.d(TAG, "handleSetMediaItems"); 81 | if (mediaItems.size() != 1) { 82 | Log.w(TAG, "handleSetMediaItems size not 1"); 83 | return Futures.immediateVoidFuture(); 84 | } 85 | MediaItem mediaItem = mediaItems.get(0); 86 | if (mediaItem == null 87 | || mediaItem.localConfiguration == null 88 | || mediaItem.mediaMetadata.extras == null 89 | || mediaItem.mediaMetadata.extras.keySet() == null) { 90 | Log.w(TAG, "mediaItem invalid"); 91 | return Futures.immediateVoidFuture(); 92 | } 93 | Log.d(TAG, "mediaItem:uri:" + mediaItem.localConfiguration.uri); 94 | for (String key : mediaItem.mediaMetadata.extras.keySet()) { 95 | Log.d(TAG, "mediaItem:" + key + ":" + mediaItem.mediaMetadata.extras.get(key)); 96 | } 97 | state = 98 | state 99 | .buildUpon() 100 | .setPlaylist( 101 | ImmutableList.of( 102 | new MediaItemData.Builder(mediaItem).setMediaItem(mediaItem).build())) 103 | .build(); 104 | return Futures.immediateVoidFuture(); 105 | } 106 | 107 | @NonNull 108 | @Override 109 | protected ListenableFuture handleStop() { 110 | Log.d(TAG, "handleStop"); 111 | state = state.buildUpon().setPlaybackState(STATE_IDLE).build(); 112 | stopStream(); 113 | return Futures.immediateVoidFuture(); 114 | } 115 | 116 | @NonNull 117 | @Override 118 | protected ListenableFuture handleRelease() { 119 | Log.d(TAG, "handleRelease"); 120 | handleStop(); 121 | wifiLockManager.setEnabled(false); 122 | return Futures.immediateVoidFuture(); 123 | } 124 | 125 | private void startStream() { 126 | if (isStreaming()) { 127 | Log.i(TAG, "startStream:already streaming"); 128 | return; 129 | } 130 | if (!state.playWhenReady || state.playbackState != STATE_READY) { 131 | Log.i(TAG, "startStream:not ready"); 132 | return; 133 | } 134 | if (state.playlist.get(0).mediaItem.mediaMetadata.extras == null) { 135 | Log.e(TAG, "startStream:no media selected"); 136 | return; 137 | } 138 | Bundle mediaItemExtra = state.playlist.get(0).mediaItem.mediaMetadata.extras; 139 | 140 | workers = 141 | new WorkerThreadPair( 142 | context, 143 | this, 144 | mediaItemExtra.getString(MusicService.DATA_IP_ADDRESS), 145 | mediaItemExtra.getInt(MusicService.DATA_AUDIO_PORT, MusicService.DEFAULT_AUDIO_PORT), 146 | mediaItemExtra.getInt(MusicService.DATA_SAMPLE_RATE, MusicService.DEFAULT_SAMPLE_RATE), 147 | mediaItemExtra.getBoolean(MusicService.DATA_STEREO, MusicService.DEFAULT_STEREO), 148 | mediaItemExtra.getInt(MusicService.DATA_BUFFER_MS, MusicService.DEFAULT_BUFFER_MS), 149 | mediaItemExtra.getBoolean(MusicService.DATA_RETRY, MusicService.DEFAULT_RETRY), 150 | mediaItemExtra.getBoolean( 151 | MusicService.DATA_USE_PERFORMANCE_MODE, MusicService.DEFAULT_USE_PERFORMANCE_MODE), 152 | mediaItemExtra.getBoolean( 153 | MusicService.DATA_USE_MIN_BUFFER, MusicService.DEFAULT_USE_MIN_BUFFER)); 154 | 155 | wifiLockManager.setStayAwake(true); 156 | } 157 | 158 | private void stopStream() { 159 | // we can also release the Wifi lock, if we're holding it 160 | wifiLockManager.setStayAwake(false); 161 | 162 | if (workers != null) { 163 | workers.stopAndInterrupt(); 164 | workers = null; 165 | } 166 | } 167 | 168 | private boolean isStreaming() { 169 | return workers != null; 170 | } 171 | 172 | public void stopPlayback() { 173 | stopStream(); 174 | invalidateState(); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/ThreadStoppable.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * Copyright (C) 2014 kaytat 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package com.kaytat.simpleprotocolplayer; 19 | 20 | class ThreadStoppable extends Thread { 21 | volatile boolean running = true; 22 | 23 | public void customStop() { 24 | running = false; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/WifiLockManager.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2020 The Android Open Source Project 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | /* 17 | This was lifted from exoplayer. 18 | */ 19 | package com.kaytat.simpleprotocolplayer; 20 | 21 | import android.content.Context; 22 | import android.net.wifi.WifiManager; 23 | import android.net.wifi.WifiManager.WifiLock; 24 | import android.util.Log; 25 | import androidx.annotation.Nullable; 26 | 27 | /** 28 | * Handles a {@link WifiLock} 29 | * 30 | *

The handling of wifi locks requires the {@link android.Manifest.permission#WAKE_LOCK} 31 | * permission. 32 | */ 33 | /* package */ final class WifiLockManager { 34 | 35 | private static final String TAG = "WifiLockManager"; 36 | private static final String WIFI_LOCK_TAG = "ExoPlayer:WifiLockManager"; 37 | 38 | @Nullable private final WifiManager wifiManager; 39 | @Nullable private WifiLock wifiLock; 40 | private boolean enabled; 41 | private boolean stayAwake; 42 | 43 | public WifiLockManager(Context context) { 44 | wifiManager = 45 | (WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE); 46 | } 47 | 48 | /** 49 | * Sets whether to enable the usage of a {@link WifiLock}. 50 | * 51 | *

By default, wifi lock handling is not enabled. Enabling will acquire the wifi lock if 52 | * necessary. Disabling will release the wifi lock if held. 53 | * 54 | *

Enabling {@link WifiLock} requires the {@link android.Manifest.permission#WAKE_LOCK}. 55 | * 56 | * @param enabled True if the player should handle a {@link WifiLock}. 57 | */ 58 | public void setEnabled(boolean enabled) { 59 | if (enabled && wifiLock == null) { 60 | if (wifiManager == null) { 61 | Log.w(TAG, "WifiManager is null, therefore not creating the WifiLock."); 62 | return; 63 | } 64 | wifiLock = wifiManager.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, WIFI_LOCK_TAG); 65 | wifiLock.setReferenceCounted(false); 66 | } 67 | 68 | this.enabled = enabled; 69 | updateWifiLock(); 70 | } 71 | 72 | /** 73 | * Sets whether to acquire or release the {@link WifiLock}. 74 | * 75 | *

The wifi lock will not be acquired unless handling has been enabled through {@link 76 | * #setEnabled(boolean)}. 77 | * 78 | * @param stayAwake True if the player should acquire the {@link WifiLock}. False if it should 79 | * release. 80 | */ 81 | public void setStayAwake(boolean stayAwake) { 82 | this.stayAwake = stayAwake; 83 | updateWifiLock(); 84 | } 85 | 86 | private void updateWifiLock() { 87 | if (wifiLock == null) { 88 | return; 89 | } 90 | 91 | if (enabled && stayAwake) { 92 | wifiLock.acquire(); 93 | } else { 94 | wifiLock.release(); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/com/kaytat/simpleprotocolplayer/WorkerThreadPair.java: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (C) 2011 The Android Open Source Project 3 | * Copyright (C) 2014 kaytat 4 | * 5 | * Licensed under the Apache License, Version 2.0 (the "License"); 6 | * you may not use this file except in compliance with the License. 7 | * You may obtain a copy of the License at 8 | * 9 | * http://www.apache.org/licenses/LICENSE-2.0 10 | * 11 | * Unless required by applicable law or agreed to in writing, software 12 | * distributed under the License is distributed on an "AS IS" BASIS, 13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | * See the License for the specific language governing permissions and 15 | * limitations under the License. 16 | */ 17 | 18 | package com.kaytat.simpleprotocolplayer; 19 | 20 | import android.content.Context; 21 | import android.media.AudioAttributes; 22 | import android.media.AudioFormat; 23 | import android.media.AudioTrack; 24 | import android.os.Handler; 25 | import android.util.Log; 26 | import android.widget.Toast; 27 | import java.util.concurrent.ArrayBlockingQueue; 28 | 29 | /** 30 | * group everything belongs a stream together, makes multi stream easier including NetworkReadThread 31 | * BufferToAudioTrackThread and AudioTrack 32 | */ 33 | public class WorkerThreadPair { 34 | 35 | interface StopPlaybackCallback { 36 | void stopPlayback(); 37 | } 38 | 39 | private static final String TAG = WorkerThreadPair.class.getSimpleName(); 40 | 41 | private final BufferToAudioTrackThread audioThread; 42 | private final NetworkReadThread networkThread; 43 | private final Context context; 44 | private final StopPlaybackCallback stopPlaybackCallback; 45 | 46 | final AudioTrack audioTrack; 47 | 48 | public WorkerThreadPair( 49 | Context context, 50 | StopPlaybackCallback stopPlaybackCallback, 51 | String serverAddr, 52 | int serverPort, 53 | int sampleRate, 54 | boolean stereo, 55 | int requestedBufferMs, 56 | boolean retry, 57 | boolean usePerformanceMode, 58 | boolean useMinBuffer) { 59 | this.context = context; 60 | this.stopPlaybackCallback = stopPlaybackCallback; 61 | int channelMask = stereo ? AudioFormat.CHANNEL_OUT_STEREO : AudioFormat.CHANNEL_OUT_MONO; 62 | 63 | // Sanitize input, just in case 64 | if (sampleRate <= 0) { 65 | sampleRate = MusicService.DEFAULT_SAMPLE_RATE; 66 | } 67 | 68 | int audioTrackMinBuffer = 69 | AudioTrack.getMinBufferSize(sampleRate, channelMask, AudioFormat.ENCODING_PCM_16BIT); 70 | Log.d(TAG, "audioTrackMinBuffer:" + audioTrackMinBuffer); 71 | 72 | if (useMinBuffer) { 73 | bytesPerAudioPacket = calcMinBytesPerAudioPacket(stereo, audioTrackMinBuffer); 74 | } else { 75 | if (requestedBufferMs <= 5) { 76 | requestedBufferMs = MusicService.DEFAULT_BUFFER_MS; 77 | } 78 | bytesPerAudioPacket = calcBytesPerAudioPacket(sampleRate, stereo, requestedBufferMs); 79 | } 80 | Log.d(TAG, "useMinBuffer:" + useMinBuffer); 81 | 82 | // The agreement here is that audioTrack will be shutdown by the helper 83 | audioTrack = buildAudioTrack(sampleRate, channelMask, audioTrackMinBuffer, usePerformanceMode); 84 | Log.d(TAG, "usePerformanceMode:" + usePerformanceMode); 85 | 86 | audioThread = new BufferToAudioTrackThread(this, "audio:" + serverAddr + ":" + serverPort); 87 | networkThread = 88 | new NetworkReadThread( 89 | this, serverAddr, serverPort, retry, "net:" + serverAddr + ":" + serverPort); 90 | 91 | audioThread.start(); 92 | networkThread.start(); 93 | } 94 | 95 | static AudioTrack buildAudioTrack( 96 | int sampleRate, int channelMask, int audioTrackMinBuffer, boolean usePerformanceMode) { 97 | AudioTrack.Builder audioTrackBuilder = 98 | new AudioTrack.Builder() 99 | .setAudioAttributes( 100 | new AudioAttributes.Builder() 101 | .setUsage(AudioAttributes.USAGE_MEDIA) 102 | .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) 103 | .build()) 104 | .setAudioFormat( 105 | new AudioFormat.Builder() 106 | .setEncoding(AudioFormat.ENCODING_PCM_16BIT) 107 | .setSampleRate(sampleRate) 108 | .setChannelMask(channelMask) 109 | .build()) 110 | .setBufferSizeInBytes(audioTrackMinBuffer) 111 | .setTransferMode(AudioTrack.MODE_STREAM); 112 | if (usePerformanceMode) { 113 | audioTrackBuilder.setPerformanceMode(AudioTrack.PERFORMANCE_MODE_LOW_LATENCY); 114 | } 115 | return audioTrackBuilder.build(); 116 | } 117 | 118 | static int calcBytesPerAudioPacket(int sampleRate, boolean stereo, int requestedBufferMs) { 119 | 120 | // Assume 16 bits per sample 121 | int bytesPerSecond = sampleRate * 2; 122 | if (stereo) { 123 | bytesPerSecond *= 2; 124 | } 125 | 126 | int result = (bytesPerSecond * requestedBufferMs) / 1000; 127 | 128 | if (stereo) { 129 | result = (result + 3) & ~0x3; 130 | } else { 131 | result = (result + 1) & ~0x1; 132 | } 133 | 134 | Log.d(TAG, "calcBytesPerAudioPacket:bytes / second:" + bytesPerSecond); 135 | Log.d(TAG, "calcBytesPerAudioPacket:" + result); 136 | 137 | return result; 138 | } 139 | 140 | static int calcMinBytesPerAudioPacket(boolean stereo, int audioTrackMinBuffer) { 141 | int bytesPerAudioPacket; 142 | 143 | if (stereo) { 144 | bytesPerAudioPacket = (audioTrackMinBuffer + 3) & ~0x3; 145 | } else { 146 | bytesPerAudioPacket = (audioTrackMinBuffer + 1) & ~0x1; 147 | } 148 | 149 | Log.d(TAG, "calcMinBytesPerAudioPacket:audioTrackMinBuffer:" + audioTrackMinBuffer); 150 | Log.d(TAG, "calcMinBytesPerAudioPacket:" + bytesPerAudioPacket); 151 | 152 | return bytesPerAudioPacket; 153 | } 154 | 155 | public static final int NUM_PACKETS = 3; 156 | 157 | // The amount of data to read from the network before sending to AudioTrack 158 | final int bytesPerAudioPacket; 159 | 160 | final ArrayBlockingQueue dataQueue = new ArrayBlockingQueue<>(NUM_PACKETS); 161 | 162 | public void stopAndInterrupt() { 163 | for (ThreadStoppable it : new ThreadStoppable[] {audioThread, networkThread}) { 164 | 165 | try { 166 | it.customStop(); 167 | it.interrupt(); 168 | 169 | // Do not join since this can take some time. The 170 | // workers should be able to shutdown independently. 171 | // t.join(); 172 | } catch (Exception e) { 173 | Log.e(TAG, "join exception:" + e); 174 | } 175 | } 176 | } 177 | 178 | public void brokenShutdown() { 179 | // Broke out of loop unexpectedly. Shutdown. 180 | Handler h = new Handler(context.getMainLooper()); 181 | Runnable r = 182 | () -> { 183 | Toast.makeText(context.getApplicationContext(), "Unable to stream", Toast.LENGTH_SHORT) 184 | .show(); 185 | stopPlaybackCallback.stopPlayback(); 186 | }; 187 | h.post(r); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /src/main/res/drawable-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/src/main/res/drawable-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /src/main/res/drawable-hdpi/ic_stat_playing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/src/main/res/drawable-hdpi/ic_stat_playing.png -------------------------------------------------------------------------------- /src/main/res/drawable-hdpi/play.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/src/main/res/drawable-hdpi/play.png -------------------------------------------------------------------------------- /src/main/res/drawable-hdpi/play_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/src/main/res/drawable-hdpi/play_pressed.png -------------------------------------------------------------------------------- /src/main/res/drawable-hdpi/stop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/src/main/res/drawable-hdpi/stop.png -------------------------------------------------------------------------------- /src/main/res/drawable-hdpi/stop_pressed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/src/main/res/drawable-hdpi/stop_pressed.png -------------------------------------------------------------------------------- /src/main/res/drawable-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/src/main/res/drawable-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /src/main/res/drawable-mdpi/ic_stat_playing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/src/main/res/drawable-mdpi/ic_stat_playing.png -------------------------------------------------------------------------------- /src/main/res/drawable-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/src/main/res/drawable-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /src/main/res/drawable-xhdpi/ic_stat_playing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kaytat/SimpleProtocolPlayer/f7120412307d4231060ffee4d3a0dfd4258bb5d7/src/main/res/drawable-xhdpi/ic_stat_playing.png -------------------------------------------------------------------------------- /src/main/res/drawable/btn_play.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/main/res/drawable/btn_stop.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/main/res/layout/main.xml: -------------------------------------------------------------------------------- 1 | 16 | 20 | 21 | 26 | 27 | 31 | 32 | 38 | 39 | 47 | 48 | 59 | 60 | 61 | 65 | 66 | 72 | 73 | 81 | 82 | 94 | 95 | 96 | 100 | 101 | 107 | 108 | 116 | 117 | 124 | 125 | 126 | 130 | 131 | 137 | 138 | 146 | 147 | 154 | 155 | 156 | 160 | 161 | 167 | 168 | 176 | 177 | 188 | 189 | 190 | 194 | 195 | 201 | 202 | 210 | 211 | 218 | 219 | 220 | 224 | 225 | 231 | 232 | 240 | 241 | 248 | 249 | 250 | 254 | 255 | 261 | 262 | 270 | 271 | 278 | 279 | 280 | 284 | 285 | 291 | 292 | 300 | 301 | 309 | 310 | 311 | 317 | 318 | 326 | 327 | 335 | 336 | 337 | 338 | 339 | 340 | -------------------------------------------------------------------------------- /src/main/res/layout/notice.xml: -------------------------------------------------------------------------------- 1 | 15 | 16 | 18 | 19 | 23 | 24 | 30 | 31 | -------------------------------------------------------------------------------- /src/main/res/menu/actions.xml: -------------------------------------------------------------------------------- 1 |

2 | 5 | 6 | -------------------------------------------------------------------------------- /src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 16 | 17 | 18 | 19 | Simple Protocol Player 20 | IP Address / Hostname 21 | IP Address / Hostname 22 | Audio Port 23 | Audio Port 24 | 12345 25 | Notice 26 | Notice 27 | Sample Rate 28 | Mono/Stereo 29 | 30 | 11025 31 | 12000 32 | 22050 33 | 24000 34 | 44100 (Default) 35 | 48000 36 | 37 | 38 | Mono 39 | Stereo (Default) 40 | 41 | 42 | Stereo 43 | Buffer size (in ms) 44 | Buffer size 45 | Enable network retries 46 | Performance mode 47 | Min AudioTrack buffer 48 | Media3 49 | Error 50 | 51 | -------------------------------------------------------------------------------- /src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |