├── .gitignore ├── LICENSE ├── README.md ├── build.gradle ├── fastlane └── metadata │ └── android │ └── en-US │ ├── changelogs │ ├── 100.txt │ ├── 101.txt │ ├── 102.txt │ ├── 103.txt │ ├── 104.txt │ ├── 105.txt │ ├── 2.txt │ ├── 200.txt │ ├── 201.txt │ ├── 3.txt │ ├── 300.txt │ ├── 301.txt │ ├── 4.txt │ ├── 400.txt │ ├── 401.txt │ ├── 402.txt │ ├── 5.txt │ ├── 500.txt │ ├── 6.txt │ ├── 600.txt │ ├── 601.txt │ ├── 602.txt │ ├── 603.txt │ ├── 7.txt │ ├── 700.txt │ ├── 8.txt │ ├── 800.txt │ ├── 801.txt │ ├── 802.txt │ ├── 803.txt │ ├── 804.txt │ └── 805.txt │ ├── full_description.txt │ ├── images │ ├── icon.png │ └── phoneScreenshots │ │ ├── browse.png │ │ ├── now-playing.png │ │ └── queue.png │ └── short_description.txt ├── gradle.properties ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradlew ├── gradlew.bat ├── lms-material ├── .gitignore ├── build.gradle ├── proguard-rules.pro └── src │ ├── androidTest │ └── java │ │ └── com │ │ └── craigd │ │ └── lmsmaterial │ │ └── app │ │ └── ExampleInstrumentedTest.java │ ├── main │ ├── AndroidManifest.xml │ ├── ic_launcher-playstore.png │ ├── java │ │ └── com │ │ │ └── craigd │ │ │ └── lmsmaterial │ │ │ └── app │ │ │ ├── ControlService.java │ │ │ ├── DownloadService.java │ │ │ ├── DownloadStatusReceiver.java │ │ │ ├── JsonRpc.java │ │ │ ├── LocalPlayer.java │ │ │ ├── MainActivity.java │ │ │ ├── PhoneStateHandler.java │ │ │ ├── ServerDiscovery.java │ │ │ ├── SettingsActivity.java │ │ │ ├── TermuxResultsService.java │ │ │ ├── UrlHandler.java │ │ │ ├── Utils.java │ │ │ └── cometd │ │ │ ├── BayeuxExtension.java │ │ │ ├── CometClient.java │ │ │ ├── ConnectionState.java │ │ │ ├── HttpStreamingTransport.java │ │ │ ├── PlayerStatus.java │ │ │ └── SlimClient.java │ └── res │ │ ├── color │ │ ├── switch_thumb_color.xml │ │ ├── switch_thumb_color_light.xml │ │ ├── switch_track_color.xml │ │ ├── switch_track_decoration_color.xml │ │ └── switch_track_decoration_color_light.xml │ │ ├── drawable │ │ ├── ic_action_quit.xml │ │ ├── ic_action_quit_light.xml │ │ ├── ic_download.xml │ │ ├── ic_launcher_background.xml │ │ ├── ic_launcher_foreground.xml │ │ ├── ic_mono_icon.xml │ │ ├── ic_next.xml │ │ ├── ic_pause.xml │ │ ├── ic_play.xml │ │ ├── ic_prev.xml │ │ └── notification_image.png │ │ ├── layout │ │ ├── activity_main.xml │ │ ├── auth_prompt.xml │ │ ├── preference_switch.xml │ │ ├── preference_switch_light.xml │ │ ├── settings_activity.xml │ │ └── url_handler.xml │ │ ├── menu │ │ ├── right_menu.xml │ │ └── right_menu_light.xml │ │ ├── mipmap-anydpi-v26 │ │ ├── ic_launcher.xml │ │ └── ic_launcher_round.xml │ │ ├── mipmap-hdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-mdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── mipmap-xxxhdpi │ │ ├── ic_launcher.webp │ │ └── ic_launcher_round.webp │ │ ├── values-fr │ │ ├── arrays.xml │ │ └── strings.xml │ │ ├── values-ru │ │ ├── arrays.xml │ │ └── strings.xml │ │ ├── values-uk │ │ ├── arrays.xml │ │ └── strings.xml │ │ ├── values-zh │ │ ├── arrays.xml │ │ └── strings.xml │ │ ├── values │ │ ├── arrays.xml │ │ ├── colors.xml │ │ ├── dimens.xml │ │ ├── strings.xml │ │ └── styles.xml │ │ └── xml │ │ ├── data_extraction_rules.xml │ │ └── root_preferences.xml │ └── test │ └── java │ └── com │ └── craigd │ └── lmsmaterial │ └── app │ └── ExampleUnitTest.java └── settings.gradle /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | *.jks 3 | .gradle 4 | /local.properties 5 | .idea 6 | .DS_Store 7 | /build 8 | /captures 9 | .externalNativeBuild 10 | .cxx 11 | lms-material/debug/ 12 | lms-material/release/ 13 | keystore.properties 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | This application *requires* that you run LMS and have the Material-Skin plugin 3 | installed. However, it is not required for Material to be set as the default 4 | skin. 5 | 6 | This app is based upon https://github.com/andreasbehnke/lms-material-app 7 | 8 | [Get it on F-Droid](https://f-droid.org/packages/com.craigd.lmsmaterial.app/) 11 | 12 | # Building and signing the app 13 | 14 | You can build the app using your own signing key. Only signed apk files can be 15 | installed by downloading, so the signing process is required. 16 | 17 | Read this documentation for details: https://developer.android.com/studio/publish/app-signing 18 | 19 | * create a jsk keystore and a key for signing app: 20 | ``` 21 | keytool -genkey -v -keystore my-release-key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias my-alias 22 | ``` 23 | * create file keystore.properties: 24 | ``` 25 | storePassword=myStorePassword 26 | keyPassword=mykeyPassword 27 | keyAlias=my-alias 28 | storeFile=my-release-key.jks 29 | ``` 30 | * secure this file: 31 | ``` 32 | chmod 600 keystore.properties 33 | ``` 34 | * build release apk: 35 | ``` 36 | ./gradlew assembleRelease 37 | ``` 38 | * move release artifact to your phone: 39 | ``` 40 | /lms-material-app/lms-material/build/outputs/apk/release/lms-material-release.apk 41 | ``` 42 | 43 | # Donations 44 | 45 | I develop this skin purely for fun, so no donations are required. However, seeing as I have been asked about this a few times, here is a link... 46 | 47 | [![paypal](https://www.paypalobjects.com/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=2X2CTDUH27V9L&source=url) 48 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | // Top-level build file where you can add configuration options common to all sub-projects/modules. 2 | 3 | buildscript { 4 | repositories { 5 | google() 6 | mavenCentral() 7 | } 8 | dependencies { 9 | classpath 'com.android.tools.build:gradle:8.4.2' 10 | 11 | // NOTE: Do not place your application dependencies here; they belong 12 | // in the individual module build.gradle files 13 | } 14 | } 15 | 16 | allprojects { 17 | repositories { 18 | google() 19 | mavenCentral() 20 | } 21 | } 22 | 23 | task clean(type: Delete) { 24 | delete rootProject.buildDir 25 | } 26 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/100.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Added 'Orientation' setting. 4 | • Added 'Enable WiFi' setting. 5 | • Added 'During phone call' setting to pause, or mute, active player, or all players. 6 | • Added 'Show notification' setting to show current player name in nofification, and display basic media controls (these do not reflect state). 7 | • Register to receive links via 'Share Link' 8 | 9 | Fixed 10 | 11 | • Update correct 'summary' field when auto-discover server. 12 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/101.txt: -------------------------------------------------------------------------------- 1 | Fixed 2 | 3 | • Send correct command when 'play' is pressed on notification. 4 | • Fix re-opening 'Application settings' if these were previously open when app put into background. 5 | • Open '/updateinfo.html' in user's browser. 6 | • Fix notification, and action, icon colors on older Android. 7 | • Register for 'share' events from YouTube app. 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/102.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Added support for servers requiring authentication. 4 | 5 | Fixed 6 | 7 | • Only open '/material' links in this app, open others in user's browser. 8 | • When set to pause or mute players during calls, get list of powered on and playing players and only control those. 9 | • When set to pause or mute players during calls, only perform action if webview was acive within 5 seconds or notification is shown. 10 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/103.txt: -------------------------------------------------------------------------------- 1 | Fixed 2 | 3 | • Pass all URLs (apart from settings, quit, and launch player) onto user's browser. 4 | 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/104.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Add ability to decrease zoom, not just increase. 4 | • Add option to control whether application is shown over lock screen or not. 5 | 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/105.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Incrementally change volume when press-and-hold hardware buttons. 4 | • Add 'Default player' setting 5 | • Add 'Only control default' setting 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/2.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Wait for 5 seconds for URL to load. If an invalid server address is entered then this will timeout. 4 | • Auto-detect LMS server. 5 | • Add button in settings dialog to detect other servers. First one found that is not the current server is used. 6 | • Show server name and address in settings dialog. 7 | 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/200.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Added download support (requires Material Skin 2.6.0, or newer). 4 | 5 | Fixed 6 | 7 | • Show default player name / ID in preferences if set. 8 | • Use Uri.Builder to construct connection URL. 9 | • Fix stopping of notification service. 10 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/201.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Don't use WebView's app cache - deprecated. 4 | • Disable webview scrollbars. 5 | • Tell MaterialSkin to not embed PDF files, and use intent to open these with phone's PDF viewer. Requires skin version 2.10.0, or later. 6 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/3.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • If page fails to load, try to discover LMS server first, then show settings page if this fails. 4 | • Add ability to zoom webview. 5 | • Show spinner if launched with no network connection. 6 | • If device has a cutout then don't hide status bar. 7 | • Use JSON port number from service discovery. 8 | 9 | Fixed 10 | 11 | • Use correct tag value for log messages. 12 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/300.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • When adding a URL via Android's share system, show a dialog asking which player to share to and add buttons for play, add, or insert. 4 | • Add option to auto-discover servers when connection is lost (enabled by default). 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/301.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Improve look of selection menus in WebView. 4 | • Set navigation and status bar colors for settings activty. 5 | • Set initial navigation and status bar colors for main activity. 6 | • Use custom toast class so that colors can be configured. 7 | • Remove margin from application settings. 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/4.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Don't recreate webiew when orientation changes. 4 | • Add option to control status bar visibility; normal, blend with toolbar, or hidden. 5 | • Add option to control navigation bar visibility; normal, blend with toolbar, or hidden. 6 | • Opt-out of WebView metrics. 7 | 8 | Fixed 9 | 10 | • No need to fade out progress animation. 11 | • Use device's display density to properly zoom webview. 12 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/400.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Add options to start local player; SB Player, SqueezePlayer, or SqueezeLite (via Termux) 4 | • Use light theme for 'Application' settings if skin is set to a light theme 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/401.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Added ability to specify Squeezelite options. 4 | 5 | Fixed 6 | 7 | • Catch exceptions when launching PDF viewer. 8 | • If receive 404 error when loading show settings page. 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/402.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • When deleting cache clear app's cache too. 4 | • If fail to connect and auto-discovery is disabled then show settings. 5 | • Make dialogs rounded. 6 | • Disable caps case of buttons. 7 | 8 | Fixed 9 | 10 | • Revert usage of light theme for settings, always use dark. 11 | • Replace forward-slashes with underscores for download filenames. 12 | • Set font scale to 100%. 13 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/5.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Round corners of square PNG icons. 4 | 5 | Fixed 6 | 7 | • Crash when switching from hidden status (or navigatation) bar to visible. 8 | • Set Android 7.1 as minimum supported version. 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/500.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Modified icon to match new Lyrion icon. 4 | • Changed name to 'Lyrion'. 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/6.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Allow manual entry of server address and port. 4 | 5 | Fixed 6 | 7 | • Correctly handle visibility of navbar when statubar is hidden. 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/600.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Set target SDK to 34. 4 | • Use CallStateListener / PhoneStateListener to handle calls. 5 | • MediaSession for Android 13+ 6 | • Material 3. 7 | • Ukrainian and Russian translations added. 8 | • Remove option to enable WiFi as this is no longer possible. 9 | • Hide 'Web page not available' and just show blank view. 10 | • Use MaterialSkin 5.2.0's top and bottom padding option to fill screen. 11 | • Remove navigation and status bar settings and replace with single fullscreen option. 12 | 13 | Fixed 14 | 15 | • Prevent backups to work-around issues with duplicated MACs, etc. 16 | • Less detailed notification icon. 17 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/601.txt: -------------------------------------------------------------------------------- 1 | Fixed 2 | 3 | • Handle screen rotation. 4 | • Fix edge-to-edge view for pre Android 14 devices. 5 | • Set Android 6.0 as minimum level. 6 | • Only draw behind status and navigation bars for Android 8.0 and above. 7 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/602.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • If zoom factor is less than default then adjust top/bottom padding. 4 | 5 | Fixed 6 | 7 | • Notifications on pre-Android 13 devices. 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/603.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Use WindowInsets to calculate top and bottom padding required. 4 | 5 | Fixed 6 | 7 | • Fix starting Squeezelite (via Termux) 8 | • Don't allow fullscreen option on devices with top-left camera cutout. 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/7.txt: -------------------------------------------------------------------------------- 1 | Fixed 2 | 3 | • Set Android 6.0 as minimum supported version. 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/700.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Add option to allow full media-session notifications, with cover art and status updates. 4 | • Light settings dialog with light themes. 5 | 6 | Fixed 7 | 8 | • Resize view when keyboard shown. 9 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/8.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Added 'Quit' action to main menu. 4 | • Added 'Quit' action to settings actionbar. 5 | • Added option to keep screen on. 6 | • Back button closes app if not connected to network. 7 | • Removed dividers in setings view. 8 | 9 | Fixed 10 | 11 | • Set Android 5.0 as minimum supported version. 12 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/800.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Added option to control if player state should be resumed after call. 4 | 5 | Fixed 6 | 7 | • Reduce network usage by setting timeout value to 0 for player status updates. 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/801.txt: -------------------------------------------------------------------------------- 1 | Fixed 2 | 3 | • Update download code for newer SDK target. 4 | • Ask for MANAGE_EXTERNAL_STORAGE permission on Android 13+ when downloading files so that cover-art can also be downloaded. If this permission is not granted music is still downloaded, but cover-art will not be. 5 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/802.txt: -------------------------------------------------------------------------------- 1 | Fixed 2 | 3 | • Fix starting of SqueezePlayer. 4 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/803.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • When opening PDFs check for an installed handler and show appropriate error if none found. 4 | • Remove 'Quit' menu option. 5 | 6 | Fixed 7 | 8 | • Save downloaded cover-art as 'albumart.jpg' 9 | • Fix bottom inset height calculation. 10 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/804.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Add 'Quit' action to pre-Android 13 notifications. 4 | • Re-add option to show 'Quit' in main menu. 5 | • If skin indicates its lost connection then after 5 seconds start discovery (if enabled) or show settings. 6 | 7 | Fixed 8 | 9 | • Stop local player when quit via settings dialog or notification, if option enabled. 10 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/changelogs/805.txt: -------------------------------------------------------------------------------- 1 | Improved 2 | 3 | • Only show 'Discovering server' toast if not due to connection issues. 4 | 5 | Fixed 6 | 7 | • When server discovered, only load if different to current. 8 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/full_description.txt: -------------------------------------------------------------------------------- 1 | Provides a WebView wrapper for accessing a Lyrion Music Server instance using MaterialSkin. Requires the MaterialSkin plugin to be installed on your LMS instance. 2 | -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/fastlane/metadata/android/en-US/images/icon.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/browse.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/fastlane/metadata/android/en-US/images/phoneScreenshots/browse.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/now-playing.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/fastlane/metadata/android/en-US/images/phoneScreenshots/now-playing.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/images/phoneScreenshots/queue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/fastlane/metadata/android/en-US/images/phoneScreenshots/queue.png -------------------------------------------------------------------------------- /fastlane/metadata/android/en-US/short_description.txt: -------------------------------------------------------------------------------- 1 | Simple webview wrapper for MaterialSkin on an LMS server 2 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx1536m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app's APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | android.defaults.buildfeatures.buildconfig=true 21 | android.nonTransitiveRClass=false 22 | android.nonFinalResIds=false 23 | 24 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Jan 23 08:34:15 GMT 2021 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.6-bin.zip 7 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 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 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /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 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 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 Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /lms-material/.gitignore: -------------------------------------------------------------------------------- 1 | /build 2 | -------------------------------------------------------------------------------- /lms-material/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | 3 | android { 4 | defaultConfig { 5 | applicationId "com.craigd.lmsmaterial.app" 6 | compileSdk 34 7 | minSdkVersion 23 8 | targetSdkVersion 34 9 | buildToolsVersion = "34.0.0" 10 | versionCode 805 11 | versionName "0.8.5" 12 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 13 | } 14 | buildTypes { 15 | release { 16 | minifyEnabled true 17 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 18 | } 19 | } 20 | namespace 'com.craigd.lmsmaterial.app' 21 | lint { 22 | checkReleaseBuilds false 23 | } 24 | } 25 | 26 | dependencies { 27 | implementation fileTree(dir: 'libs', include: ['*.jar']) 28 | implementation 'androidx.appcompat:appcompat:1.7.0' 29 | implementation 'androidx.constraintlayout:constraintlayout:2.1.4' 30 | implementation 'androidx.preference:preference:1.2.1' 31 | implementation 'androidx.media:media:1.7.0' 32 | implementation 'com.android.volley:volley:1.2.1' 33 | implementation 'com.google.android.material:material:1.12.0' 34 | implementation 'io.github.muddz:styleabletoast:2.4.0' 35 | implementation 'org.cometd.java:cometd-java-client:3.1.11' 36 | implementation 'org.slf4j:slf4j-nop:1.7.30' 37 | implementation platform("org.jetbrains.kotlin:kotlin-bom:1.9.20") 38 | testImplementation 'junit:junit:4.13.2' 39 | androidTestImplementation 'androidx.test.ext:junit:1.2.1' 40 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.6.1' 41 | } 42 | -------------------------------------------------------------------------------- /lms-material/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile 22 | -------------------------------------------------------------------------------- /lms-material/src/androidTest/java/com/craigd/lmsmaterial/app/ExampleInstrumentedTest.java: -------------------------------------------------------------------------------- 1 | package com.craigd.lmsmaterial.app; 2 | 3 | import android.content.Context; 4 | import androidx.test.platform.app.InstrumentationRegistry; 5 | import androidx.test.ext.junit.runners.AndroidJUnit4; 6 | 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | 10 | import static org.junit.Assert.*; 11 | 12 | /** 13 | * Instrumented test, which will execute on an Android device. 14 | * 15 | * @see Testing documentation 16 | */ 17 | @RunWith(AndroidJUnit4.class) 18 | public class ExampleInstrumentedTest { 19 | @Test 20 | public void useAppContext() { 21 | // Context of the app under test. 22 | Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); 23 | 24 | assertEquals("com.craigd.lmsmaterial.app", appContext.getPackageName()); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /lms-material/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 38 | 42 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 80 | 81 | 82 | 83 | 84 | 85 | 89 | 90 | 93 | 94 | 97 | 98 | 99 | 100 | 101 | 104 | 105 | 106 | 107 | 108 | 111 | 112 | 113 | 114 | 115 | 116 | 119 | 120 | 121 | 122 | 123 | 124 | 127 | 128 | 129 | 130 | -------------------------------------------------------------------------------- /lms-material/src/main/ic_launcher-playstore.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/lms-material/src/main/ic_launcher-playstore.png -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/DownloadService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * Copyright (c) 2020-2023 Craig Drummond 5 | * MIT license. 6 | */ 7 | 8 | package com.craigd.lmsmaterial.app; 9 | 10 | import android.annotation.SuppressLint; 11 | import android.app.DownloadManager; 12 | import android.app.Notification; 13 | import android.app.NotificationChannel; 14 | import android.app.NotificationManager; 15 | import android.app.PendingIntent; 16 | import android.app.Service; 17 | import android.content.Context; 18 | import android.content.Intent; 19 | import android.content.SharedPreferences; 20 | import android.content.pm.ServiceInfo; 21 | import android.net.Uri; 22 | import android.os.Build; 23 | import android.os.Environment; 24 | import android.os.Handler; 25 | import android.os.IBinder; 26 | import android.os.Looper; 27 | import android.os.Message; 28 | import android.os.Messenger; 29 | 30 | import androidx.annotation.NonNull; 31 | import androidx.annotation.RequiresApi; 32 | import androidx.core.app.NotificationCompat; 33 | import androidx.core.app.NotificationManagerCompat; 34 | import androidx.core.app.ServiceCompat; 35 | import androidx.preference.PreferenceManager; 36 | 37 | import org.json.JSONArray; 38 | import org.json.JSONException; 39 | import org.json.JSONObject; 40 | 41 | import java.io.File; 42 | import java.io.FileInputStream; 43 | import java.io.FileOutputStream; 44 | import java.io.InputStream; 45 | import java.io.OutputStream; 46 | import java.lang.ref.WeakReference; 47 | import java.util.HashSet; 48 | import java.util.LinkedList; 49 | import java.util.List; 50 | import java.util.Set; 51 | 52 | public class DownloadService extends Service { 53 | private static final String COVER_ART_SRC = "cover.jpg"; 54 | private static final String COVER_ART_DEST = "albumart.jpg"; 55 | public static final String STATUS = DownloadService.class.getCanonicalName() + ".STATUS"; 56 | public static final String STATUS_BODY = "body"; 57 | public static final String STATUS_LEN = "len"; 58 | public static final int DOWNLOAD_LIST = 1; 59 | public static final int CANCEL_LIST = 2; 60 | public static final int STATUS_REQ = 3; 61 | private static final int MSG_ID = 2; 62 | private static final int MAX_QUEUED_ITEMS = 4; 63 | public static final String NOTIFICATION_CHANNEL_ID = "lms_download_service"; 64 | 65 | private DownloadManager downloadManager; 66 | private SharedPreferences sharedPreferences; 67 | private NotificationCompat.Builder notificationBuilder; 68 | private NotificationManagerCompat notificationManager; 69 | private final Messenger messenger = new Messenger(new IncomingHandler(this)); 70 | 71 | static String getString(JSONObject obj, String key) { 72 | try { 73 | return obj.getString(key); 74 | } catch (JSONException e) { 75 | return null; 76 | } 77 | } 78 | 79 | static int getInt(JSONObject obj, String key) { 80 | try { 81 | return obj.getInt(key); 82 | } catch (JSONException e) { 83 | return 0; 84 | } 85 | } 86 | 87 | static String fatSafe(String str) { 88 | return str.replaceAll("[?<>\\\\:*|\"/]", "_"); 89 | } 90 | 91 | static String fixEmpty(String str) { 92 | return Utils.isEmpty(str) ? "Unknown" : str; 93 | } 94 | 95 | static class DownloadItem { 96 | public DownloadItem(JSONObject obj, boolean transcode) { 97 | id = getInt(obj, "id"); 98 | filename = getString(obj, "filename"); 99 | title = getString(obj, "title"); 100 | ext = getString(obj, "ext"); 101 | artist = getString(obj, "artist"); 102 | album = getString(obj, "album"); 103 | tracknum = getInt(obj, "tracknum"); 104 | disc = getInt(obj, "disc"); 105 | albumId = getInt(obj, "album_id"); 106 | isTrack = true; 107 | 108 | if (Utils.isEmpty(filename)) { 109 | filename = ""; 110 | if (disc > 0) { 111 | filename += disc; 112 | } 113 | if (tracknum > 0) { 114 | filename += (!filename.isEmpty() ? "." : "") + (tracknum < 10 ? "0" : "") + tracknum + " "; 115 | } else if (!filename.isEmpty()) { 116 | filename += " "; 117 | } 118 | filename += fixEmpty(title) + "." + (transcode ? "mp3" : ext); 119 | } else if (transcode) { 120 | int pos = filename.lastIndexOf('.'); 121 | filename = (pos > 0 ? filename.substring(0, pos) : filename) + ".mp3"; 122 | } 123 | } 124 | 125 | public DownloadItem(int id, int albumId, String artist, String album) { 126 | isTrack = false; 127 | this.id = id * -1; // Ensure we do not overlap with track id 128 | this.albumId = albumId; 129 | this.artist = artist; 130 | this.album = album; 131 | this.filename = COVER_ART_DEST; 132 | } 133 | 134 | JSONObject toObject(boolean downloading) throws JSONException { 135 | JSONObject obj = new JSONObject(); 136 | obj.put("id", id); 137 | obj.put("downloading", downloading); 138 | obj.put("title", filename); 139 | obj.put("subtitle", artist + " - " + album); 140 | return obj; 141 | } 142 | 143 | public String getFolder() { 144 | return fatSafe(!artist.isEmpty() && !album.isEmpty() 145 | ? artist + " - " + album 146 | : !artist.isEmpty() 147 | ? artist 148 | : !album.isEmpty() 149 | ? album 150 | : "Unknown"); 151 | } 152 | 153 | public String getDownloadFileName() { 154 | return fatSafe(fixEmpty(artist) + " - " + fixEmpty(album) + " - " + filename); 155 | } 156 | 157 | public int id; 158 | public String filename; 159 | public String title; 160 | public String ext; 161 | public String artist; 162 | public String album; 163 | public int tracknum; 164 | public int disc; 165 | public int albumId; 166 | public boolean isTrack; 167 | public long downloadId = 0; 168 | } 169 | 170 | final List items = new LinkedList<>(); 171 | List queuedItems = new LinkedList<>(); 172 | Set trackIds = new HashSet<>(); 173 | Set albumIds = new HashSet<>(); 174 | 175 | private static class IncomingHandler extends Handler { 176 | private final WeakReference serviceRef; 177 | public IncomingHandler(DownloadService service) { 178 | super(Looper.getMainLooper()); 179 | serviceRef = new WeakReference<>(service); 180 | } 181 | @Override 182 | public void handleMessage(@NonNull Message msg) { 183 | DownloadService srv = serviceRef.get(); 184 | if (null==srv) { 185 | super.handleMessage(msg); 186 | return; 187 | } 188 | switch (msg.what) { 189 | case DOWNLOAD_LIST: 190 | srv.addTracks((JSONArray) msg.obj); 191 | break; 192 | case CANCEL_LIST: 193 | srv.cancel((JSONArray) msg.obj); 194 | break; 195 | case STATUS_REQ: 196 | srv.sendStatusUpdate(); 197 | break; 198 | default: 199 | super.handleMessage(msg); 200 | } 201 | } 202 | } 203 | 204 | public DownloadService() { 205 | } 206 | 207 | @Override 208 | public IBinder onBind(Intent intent) { 209 | return messenger.getBinder(); 210 | } 211 | 212 | @Override 213 | public void onCreate() { 214 | super.onCreate(); 215 | Utils.debug(""); 216 | DownloadStatusReceiver.init(this); 217 | startForegroundService(); 218 | downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE); 219 | sharedPreferences = PreferenceManager.getDefaultSharedPreferences(this); 220 | } 221 | 222 | private void startForegroundService() { 223 | Utils.debug("Start download service."); 224 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 225 | createNotificationChannel(); 226 | } else { 227 | notificationBuilder = new NotificationCompat.Builder(this); 228 | } 229 | createNotification(); 230 | } 231 | 232 | @RequiresApi(Build.VERSION_CODES.O) 233 | private void createNotificationChannel() { 234 | notificationManager = NotificationManagerCompat.from(this); 235 | NotificationChannel chan = new NotificationChannel(NOTIFICATION_CHANNEL_ID, getApplicationContext().getResources().getString(R.string.download_notification), NotificationManager.IMPORTANCE_LOW); 236 | chan.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); 237 | chan.setShowBadge(false); 238 | chan.enableLights(false); 239 | chan.enableVibration(false); 240 | chan.setSound(null, null); 241 | notificationManager.createNotificationChannel(chan); 242 | notificationBuilder = new NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID); 243 | } 244 | 245 | @SuppressLint("MissingPermission") 246 | private void createNotification() { 247 | if (!Utils.notificationAllowed(this, NOTIFICATION_CHANNEL_ID)) { 248 | Utils.error("Permission not granted"); 249 | return; 250 | } 251 | Intent intent = new Intent(this, MainActivity.class); 252 | PendingIntent pendingIntent = PendingIntent.getActivity(this, 0, intent, Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : PendingIntent.FLAG_UPDATE_CURRENT); 253 | Notification notification = notificationBuilder.setOngoing(true) 254 | .setOnlyAlertOnce(true) 255 | .setSmallIcon(R.drawable.ic_download) 256 | .setContentTitle(getResources().getString(R.string.downloading)) 257 | .setCategory(Notification.CATEGORY_SERVICE) 258 | .setContentIntent(pendingIntent) 259 | .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) 260 | .setVibrate(null) 261 | .setSound(null) 262 | .setShowWhen(false) 263 | .setChannelId(NOTIFICATION_CHANNEL_ID) 264 | .build(); 265 | 266 | notificationManager = NotificationManagerCompat.from(this); 267 | notificationManager.notify(MSG_ID, notificationBuilder.build()); 268 | // startForeground(MSG_ID, notification); 269 | 270 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 271 | startForegroundService(new Intent(this, DownloadService.class)); 272 | } else { 273 | startService(new Intent(this, DownloadService.class)); 274 | } 275 | 276 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 277 | ServiceCompat.startForeground(this, MSG_ID, notification, Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE ? ServiceInfo.FOREGROUND_SERVICE_TYPE_SHORT_SERVICE : 0); 278 | } 279 | } 280 | 281 | void addTracks(JSONArray tracks) { 282 | Utils.debug(""); 283 | boolean transcode = sharedPreferences.getBoolean("transcode", false); 284 | 285 | synchronized (items) { 286 | try { 287 | int before = items.size(); 288 | List newAlbumCovers = new LinkedList<>(); 289 | for (int i = 0; i < tracks.length(); ++i) { 290 | DownloadItem track = new DownloadItem((JSONObject) tracks.get(i), transcode); 291 | if (!trackIds.contains(track.id)) { 292 | trackIds.add(track.id); 293 | items.add(track); 294 | if (!albumIds.contains(track.albumId)) { 295 | albumIds.add(track.albumId); 296 | newAlbumCovers.add(new DownloadItem(track.id, track.albumId, track.artist, track.album)); 297 | } 298 | } 299 | } 300 | items.addAll(newAlbumCovers); 301 | Utils.debug("Before: " + before + " now:"+ items.size()); 302 | if (before!=items.size()) { 303 | if (0==before) { 304 | downloadItems(); 305 | } else { 306 | sendStatusUpdate(); 307 | } 308 | } 309 | } catch (JSONException e) { 310 | Utils.error("Failed to add tracks", e); 311 | } 312 | } 313 | } 314 | 315 | void cancel(JSONArray ids) { 316 | Utils.debug(""); 317 | List toRemove = new LinkedList<>(); 318 | List toRemoveQueued = new LinkedList<>(); 319 | Set idSet = new HashSet<>(); 320 | for (int i = 0; i < ids.length(); ++i) { 321 | try { 322 | idSet.add(ids.getInt(i)); 323 | } catch (JSONException e) { 324 | Utils.error("Failed to decode cancel array", e); 325 | } 326 | } 327 | if (!idSet.isEmpty()) { 328 | synchronized (items) { 329 | for (int i = 0; i < items.size(); ++i) { 330 | if (idSet.contains(items.get(i).id)) { 331 | toRemove.add(items.get(i)); 332 | } 333 | } 334 | for (int i = 0; i < queuedItems.size(); ++i) { 335 | if (idSet.contains(queuedItems.get(i).id)) { 336 | toRemoveQueued.add(queuedItems.get(i)); 337 | } 338 | } 339 | Utils.debug("Remove " + toRemove.size() + " item(s)"); 340 | if (!toRemove.isEmpty()) { 341 | for (DownloadItem item : toRemove) { 342 | items.remove(item); 343 | } 344 | } 345 | if (!toRemoveQueued.isEmpty()) { 346 | for (DownloadItem item : toRemoveQueued) { 347 | downloadManager.remove(item.downloadId); 348 | queuedItems.remove(item); 349 | } 350 | } 351 | 352 | if (!toRemove.isEmpty() || !toRemoveQueued.isEmpty()) { 353 | sendStatusUpdate(); 354 | if (items.isEmpty()) { 355 | Utils.debug("Empty, so stop"); 356 | stop(); 357 | } else if (!toRemoveQueued.isEmpty()) { 358 | downloadItems(); 359 | } 360 | } 361 | } 362 | } 363 | } 364 | 365 | void sendStatusUpdate() { 366 | JSONArray update = new JSONArray(); 367 | int count = 0; 368 | Utils.debug("Items:" + items.size()+" Queued:"+queuedItems.size()); 369 | synchronized (items) { 370 | for (DownloadItem item: queuedItems) { 371 | try { 372 | update.put(item.toObject(true)); 373 | count++; 374 | } catch (JSONException e) { 375 | Utils.error("Failed to create item string", e); 376 | } 377 | } 378 | for (DownloadItem item: items) { 379 | try { 380 | update.put(item.toObject(false)); 381 | count++; 382 | } catch (JSONException e) { 383 | Utils.error("Failed to create item string", e); 384 | } 385 | } 386 | } 387 | try { 388 | Utils.debug("Send status update to webview, count:" + count); 389 | Intent intent = new Intent(); 390 | intent.setAction(STATUS); 391 | intent.putExtra(STATUS_BODY, update.toString(0)); 392 | intent.putExtra(STATUS_LEN, count); 393 | sendBroadcast(intent); 394 | } catch (JSONException e) { 395 | Utils.error("Failed to create update string", e); 396 | } 397 | } 398 | 399 | void downloadItems() { 400 | Utils.debug(""); 401 | synchronized (items) { 402 | while(!items.isEmpty() && queuedItems.size() 0) { 482 | outputStream.write(b, 0, bytesRead); 483 | } 484 | } catch (Exception e) { 485 | Utils.error("Failed to copy " + sourceFile.getAbsolutePath() + " to " + destFile.getAbsolutePath(), e); 486 | } 487 | try { 488 | if (!sourceFile.delete()) { 489 | Utils.error("Failed to delete " + sourceFile.getAbsolutePath()); 490 | } 491 | } catch (Exception e) { 492 | Utils.error("Failed to delete " + sourceFile.getAbsolutePath(), e); 493 | } 494 | } 495 | 496 | void stop() { 497 | Utils.debug(""); 498 | stopForeground(true); 499 | stopSelf(); 500 | } 501 | } 502 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/DownloadStatusReceiver.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * Copyright (c) 2020-2023 Craig Drummond 5 | * MIT license. 6 | */ 7 | 8 | package com.craigd.lmsmaterial.app; 9 | 10 | import android.app.DownloadManager; 11 | import android.content.BroadcastReceiver; 12 | import android.content.Context; 13 | import android.content.Intent; 14 | 15 | public class DownloadStatusReceiver extends BroadcastReceiver { 16 | public static final String SHOW_DOWNLOADS_ACT = "showdownloads"; 17 | 18 | private static DownloadService service = null; 19 | 20 | public static void init(DownloadService srv) { 21 | service = srv; 22 | } 23 | @Override 24 | public void onReceive(Context context, Intent intent) { 25 | if (DownloadManager.ACTION_NOTIFICATION_CLICKED.equals(intent.getAction())) { 26 | Intent startIntent = new Intent(context, MainActivity.class).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); 27 | startIntent.setAction(SHOW_DOWNLOADS_ACT); 28 | // TODO: MainActivity needs rto look at act and call relevant javascript 29 | context.startActivity(startIntent); 30 | } else if (DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) { 31 | service.downloadComplete(intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/JsonRpc.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * Copyright (c) 2020-2023 Craig Drummond 5 | * MIT license. 6 | */ 7 | 8 | package com.craigd.lmsmaterial.app; 9 | 10 | import static com.craigd.lmsmaterial.app.MainActivity.LMS_PASSWORD_KEY; 11 | import static com.craigd.lmsmaterial.app.MainActivity.LMS_USERNAME_KEY; 12 | 13 | import android.content.Context; 14 | import android.content.SharedPreferences; 15 | 16 | import androidx.annotation.Nullable; 17 | import androidx.preference.PreferenceManager; 18 | 19 | import com.android.volley.AuthFailureError; 20 | import com.android.volley.RequestQueue; 21 | import com.android.volley.Response; 22 | import com.android.volley.toolbox.JsonObjectRequest; 23 | import com.android.volley.toolbox.Volley; 24 | 25 | import org.eclipse.jetty.util.B64Code; 26 | import org.json.JSONArray; 27 | import org.json.JSONObject; 28 | 29 | import java.util.HashMap; 30 | import java.util.Map; 31 | 32 | public class JsonRpc { 33 | private final RequestQueue requestQueue; 34 | private final SharedPreferences prefs ; 35 | 36 | private class Request extends JsonObjectRequest { 37 | public Request(String url, @Nullable JSONObject request, Response.Listener responseListener) { 38 | super(Request.Method.POST, url, request, responseListener, null); 39 | } 40 | 41 | @Override 42 | public Map getHeaders() throws AuthFailureError { 43 | String user = prefs.getString(LMS_USERNAME_KEY, ""); 44 | String pass = prefs.getString(LMS_PASSWORD_KEY, ""); 45 | 46 | if (user.isEmpty() || pass.isEmpty()) { 47 | return super.getHeaders(); 48 | } 49 | 50 | Map headers = super.getHeaders(); 51 | if (null==headers) { 52 | headers = new HashMap<>(); 53 | headers.put("Authorization", "Basic " + B64Code.encode(user + ":" + pass)); 54 | } 55 | return headers; 56 | } 57 | }; 58 | 59 | public JsonRpc(Context context) { 60 | prefs = PreferenceManager.getDefaultSharedPreferences(context); 61 | requestQueue = Volley.newRequestQueue(context); 62 | } 63 | 64 | public void sendMessage(String id, String[] command) { 65 | sendMessage(id, command, null); 66 | } 67 | 68 | public void sendMessage(String id, String[] command, Response.Listener responseListener) { 69 | ServerDiscovery.Server server = new ServerDiscovery.Server(prefs.getString(SettingsActivity.SERVER_PREF_KEY,null)); 70 | if (null!=server.ip) { 71 | try { 72 | JSONObject request = new JSONObject(); 73 | JSONArray params = new JSONArray(); 74 | JSONArray cmd = new JSONArray(); 75 | params.put(0, id); 76 | for (String c : command) { 77 | cmd.put(cmd.length(), c); 78 | } 79 | params.put(1, cmd); 80 | request.put("id", 1); 81 | request.put("method", "slim.request"); 82 | request.put("params", params); 83 | 84 | Utils.info("MSG:" + request); 85 | requestQueue.add(new Request("http://" + server.ip + ":" + server.port + "/jsonrpc.js", request, responseListener)); 86 | } catch (Exception e) { 87 | Utils.error("Failed to send control message", e); 88 | } 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/LocalPlayer.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * Copyright (c) 2020-2023 Craig Drummond 5 | * MIT license. 6 | */ 7 | 8 | package com.craigd.lmsmaterial.app; 9 | 10 | import android.annotation.SuppressLint; 11 | import android.app.PendingIntent; 12 | import android.content.Context; 13 | import android.content.Intent; 14 | import android.content.SharedPreferences; 15 | import android.content.pm.PackageManager; 16 | import android.os.Build; 17 | import android.provider.Settings; 18 | import android.widget.Toast; 19 | 20 | import androidx.core.content.ContextCompat; 21 | 22 | import java.util.HashMap; 23 | import java.util.LinkedList; 24 | import java.util.List; 25 | import java.util.Map; 26 | import java.util.Random; 27 | 28 | import io.github.muddz.styleabletoast.StyleableToast; 29 | 30 | public class LocalPlayer { 31 | public static final String NO_PLAYER = "none"; 32 | public static final String SB_PLAYER = "sbplayer"; 33 | public static final String SQUEEZE_PLAYER = "squeezeplayer"; 34 | public static final String TERMUX_PLAYER = "termux"; 35 | public static final String TERMUX_MAC_PREF = "termux_mac"; 36 | 37 | private final SharedPreferences sharedPreferences; 38 | private final Context context; 39 | private JsonRpc rpc = null; 40 | 41 | private enum State { 42 | INITIAL, 43 | STARTED, 44 | STOPPED 45 | } 46 | private static State state = State.INITIAL; 47 | 48 | public LocalPlayer(SharedPreferences sharedPreferences, Context context) { 49 | this.sharedPreferences = sharedPreferences; 50 | this.context = context; 51 | } 52 | 53 | public void autoStart(boolean fromResume) { 54 | // If resuming and user had stopped this player, then don't auto-restart 55 | if (fromResume && State.STOPPED.equals(state)) { 56 | return; 57 | } 58 | if (sharedPreferences.getBoolean(SettingsActivity.AUTO_START_PLAYER_APP_PREF_KEY, false)) { 59 | // Only SqueezePlayer needs re-starting from resume??? 60 | if (!fromResume || SQUEEZE_PLAYER.equals(sharedPreferences.getString(SettingsActivity.PLAYER_APP_PREF_KEY, null))) { 61 | start(); 62 | } 63 | } 64 | } 65 | 66 | public void autoStop() { 67 | if (sharedPreferences.getBoolean(SettingsActivity.STOP_APP_ON_QUIT_PREF_KEY, false)) { 68 | stop(); 69 | } 70 | } 71 | 72 | @SuppressLint("SdCardPath") 73 | public void start() { 74 | String playerApp = sharedPreferences.getString(SettingsActivity.PLAYER_APP_PREF_KEY, null); 75 | Utils.debug("Start player: " + playerApp); 76 | if (SB_PLAYER.equals(playerApp)) { 77 | if (sendSbPlayerIntent(true)) { 78 | state = State.STARTED; 79 | } 80 | } else if (SQUEEZE_PLAYER.equals(playerApp)) { 81 | if (controlSqueezePlayer(true)) { 82 | state = State.STARTED; 83 | } 84 | } else if (TERMUX_PLAYER.equals(playerApp)) { 85 | // First check Squeezelite is not already running... 86 | runTermuxCommand("/data/data/com.termux/files/usr/bin/ps", new String[]{"-eaf"}, true); 87 | } 88 | } 89 | 90 | @SuppressLint("SdCardPath") 91 | public void startTermuxSqueezeLite() { 92 | ServerDiscovery.Server current = new ServerDiscovery.Server(sharedPreferences.getString(SettingsActivity.SERVER_PREF_KEY, null)); 93 | state = State.INITIAL; 94 | String opts = sharedPreferences.getString(SettingsActivity.SQUEEZELITE_OPTIONS_KEY, ""); 95 | Map params = new HashMap<>(); 96 | params.put("-M", "SqueezeLiteAndroid"); 97 | params.put("-C", "5"); 98 | params.put("-s", current.ip); 99 | params.put("-m", getTermuxMac()); 100 | params.put("-n", Settings.Global.getString(context.getContentResolver(), "device_name")); 101 | String[] parts = opts.split(" "); 102 | if (parts.length>1 && parts.length%2 == 0) { 103 | for (int i=0; i entry: params.entrySet()) { 116 | args[i]=entry.getKey(); 117 | i++; 118 | args[i]=entry.getValue(); 119 | i++; 120 | } 121 | if (runTermuxCommand("/data/data/com.termux/files/usr/bin/squeezelite", args, false)) { 122 | state = State.STARTED; 123 | } 124 | } 125 | 126 | public void stopPlayer(String playerId) { 127 | // If stopping player via skin's 'power' button, then we need to ask LMS to forget 128 | // the client first, and then do the actual stop. 129 | if (null==rpc) { 130 | rpc = new JsonRpc(context); 131 | } 132 | rpc.sendMessage(playerId, new String[]{"client", "forget"}, response -> stop()); 133 | } 134 | 135 | @SuppressLint("SdCardPath") 136 | public void stop() { 137 | String playerApp = sharedPreferences.getString(SettingsActivity.PLAYER_APP_PREF_KEY, null); 138 | Utils.debug("Stop player: " + playerApp); 139 | if (SB_PLAYER.equals(playerApp)) { 140 | if (sendSbPlayerIntent(false)) { 141 | state = State.STOPPED; 142 | } 143 | } else if (SQUEEZE_PLAYER.equals(playerApp)) { 144 | if (controlSqueezePlayer(false)) { 145 | state = State.STOPPED; 146 | } 147 | } else if (TERMUX_PLAYER.equals(playerApp)) { 148 | if (runTermuxCommand("/data/data/com.termux/files/usr/bin/killall", new String[]{"-9", "squeezelite"}, false)) { 149 | state = State.STOPPED; 150 | } 151 | } 152 | } 153 | 154 | private String getTermuxMac() { 155 | String mac = sharedPreferences.getString(TERMUX_MAC_PREF, null); 156 | if (null!=mac) { 157 | return mac; 158 | } 159 | List parts = new LinkedList<>(); 160 | Random rand = new Random(); 161 | parts.add("ab"); 162 | parts.add("cd"); 163 | while (parts.size()<6) { 164 | parts.add(String.format("%02x", rand.nextInt(255))); 165 | } 166 | String newMac = String.join(":", parts); 167 | SharedPreferences.Editor editor = sharedPreferences.edit(); 168 | editor.putString(TERMUX_MAC_PREF, newMac); 169 | editor.apply(); 170 | return newMac; 171 | } 172 | 173 | private boolean sendSbPlayerIntent(boolean start) { 174 | Intent intent = new Intent(); 175 | intent.setClassName("com.angrygoat.android.sbplayer", "com.angrygoat.android.sbplayer.SBPlayerReceiver"); 176 | intent.setAction("com.angrygoat.android.sbplayer." + (start ? "LAUNCH" : "EXIT")); 177 | intent.addFlags(Intent.FLAG_INCLUDE_STOPPED_PACKAGES); 178 | try { 179 | context.sendBroadcast(intent); 180 | return true; 181 | } catch (Exception e) { 182 | Utils.error("Failed to control SB Player - " + e.getMessage()); 183 | return false; 184 | } 185 | } 186 | 187 | private boolean controlSqueezePlayer(boolean start) { 188 | Intent intent = new Intent(); 189 | intent.setClassName("de.bluegaspode.squeezeplayer", "de.bluegaspode.squeezeplayer.playback.service.PlaybackService"); 190 | 191 | ServerDiscovery.Server current = new ServerDiscovery.Server(sharedPreferences.getString(SettingsActivity.SERVER_PREF_KEY, null)); 192 | intent.putExtra("forceSettingsFromIntent", true); 193 | intent.putExtra("intentHasServerSettings", true); 194 | intent.putExtra("serverURL", current.ip + ":" + current.port); 195 | intent.putExtra("serverName", current.name); 196 | String user = sharedPreferences.getString(MainActivity.LMS_USERNAME_KEY, null); 197 | String pass = sharedPreferences.getString(MainActivity.LMS_PASSWORD_KEY, null); 198 | if (user != null && pass!=null) { 199 | intent.putExtra("username", user); 200 | intent.putExtra("password", pass); 201 | } 202 | try { 203 | if (start) { 204 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 205 | context.startForegroundService(intent); 206 | } else { 207 | context.startService(intent); 208 | } 209 | } else { 210 | context.stopService(intent); 211 | } 212 | return true; 213 | } catch (Exception e) { 214 | Utils.error("Failed to control SqueezePlayer - " + e.getMessage()); 215 | return false; 216 | } 217 | } 218 | 219 | private boolean runTermuxCommand(String app, String[] args, boolean handleResp) { 220 | if (ContextCompat.checkSelfPermission(context, SettingsActivity.TERMUX_PERMISSION) != PackageManager.PERMISSION_GRANTED) { 221 | StyleableToast.makeText(context, context.getResources().getString(R.string.no_termux_run_perms), Toast.LENGTH_SHORT, R.style.toast).show(); 222 | return false; 223 | } 224 | Intent intent = new Intent(); 225 | intent.setClassName("com.termux", "com.termux.app.RunCommandService"); 226 | intent.setAction("com.termux.RUN_COMMAND"); 227 | intent.putExtra("com.termux.RUN_COMMAND_PATH", app); 228 | intent.putExtra("com.termux.RUN_COMMAND_ARGUMENTS", args); 229 | intent.putExtra("com.termux.RUN_COMMAND_BACKGROUND", true); 230 | intent.putExtra("com.termux.RUN_COMMAND_SESSION_ACTION", "0"); 231 | if (handleResp) { 232 | Utils.debug("HANDLE RESP"); 233 | int executionId = TermuxResultsService.getNextExecutionId(); 234 | Intent serviceIntent = new Intent(context, TermuxResultsService.class); 235 | serviceIntent.putExtra(TermuxResultsService.EXTRA_EXECUTION_ID, executionId); 236 | PendingIntent pendingIntent = PendingIntent.getService(context, executionId, serviceIntent, 237 | PendingIntent.FLAG_ONE_SHOT | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S ? PendingIntent.FLAG_MUTABLE : 0)); 238 | intent.putExtra("com.termux.RUN_COMMAND_PENDING_INTENT", pendingIntent); 239 | } 240 | Utils.debug("Send Termux command:"+app+" args:"+String.join(", ", args)); 241 | try { 242 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { 243 | context.startForegroundService(intent); 244 | } else { 245 | context.startService(intent); 246 | } 247 | return true; 248 | } catch (Exception e) { 249 | Utils.error("Failed to send Termux command - " + e.getMessage()); 250 | return false; 251 | } 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/PhoneStateHandler.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * Copyright (c) 2020-2023 Craig Drummond 5 | * MIT license. 6 | */ 7 | 8 | package com.craigd.lmsmaterial.app; 9 | 10 | import android.content.Context; 11 | import android.content.SharedPreferences; 12 | import android.telephony.TelephonyManager; 13 | 14 | import androidx.preference.PreferenceManager; 15 | 16 | import com.android.volley.Response; 17 | 18 | import org.json.JSONArray; 19 | import org.json.JSONException; 20 | import org.json.JSONObject; 21 | 22 | import java.util.LinkedList; 23 | import java.util.List; 24 | 25 | public class PhoneStateHandler { 26 | public static final String DO_NOTHING = "nothing"; 27 | public static final String MUTE_ALL = "muteall"; 28 | public static final String MUTE_CURRENT = "mutecurrent"; 29 | public static final String PAUSE_ALL = "pauseall"; 30 | public static final String PAUSE_CURRENT = "pausecurrent"; 31 | 32 | private SharedPreferences prefs = null; 33 | private JsonRpc rpc = null; 34 | private final List activePlayers = new LinkedList<>(); 35 | private boolean inCall = false; 36 | 37 | private final Response.Listener rpcResponse = response -> { 38 | activePlayers.clear(); 39 | if (inCall) { 40 | try { 41 | Utils.debug("RESP" + response.toString(4)); 42 | JSONObject result = response.getJSONObject("result"); 43 | if (result.has("players")) { 44 | JSONArray players = result.getJSONArray("players"); 45 | if (players.length() > 0) { 46 | for (int i = 0; i < players.length(); ++i) { 47 | activePlayers.add(players.getJSONObject(i).getString("id")); 48 | } 49 | Utils.debug("RPC response, activePlayers:" + activePlayers); 50 | controlPlayers(); 51 | } 52 | } 53 | } catch (JSONException e) { 54 | Utils.error("Failed to parse response", e); 55 | } 56 | } 57 | }; 58 | 59 | public void handle(Context context, int state) { 60 | if (null==prefs) { 61 | prefs = PreferenceManager.getDefaultSharedPreferences(context); 62 | } 63 | String action = prefs.getString(SettingsActivity.ON_CALL_PREF_KEY, DO_NOTHING); 64 | Utils.debug("Call state:" + state + ", action: " + action); 65 | 66 | if (DO_NOTHING.equals(action)) { 67 | return; 68 | } 69 | if (null==rpc) { 70 | rpc = new JsonRpc(context); 71 | } 72 | if (state == TelephonyManager.CALL_STATE_RINGING || state == TelephonyManager.CALL_STATE_OFFHOOK) { 73 | callStarted(action); 74 | } else { 75 | callEnded(); 76 | } 77 | } 78 | 79 | private void callStarted(String action) { 80 | Utils.debug("Call started, activePlayers:"+activePlayers); 81 | if (MainActivity.isActive() || ControlService.isActive()) { 82 | inCall = true; 83 | if (MUTE_CURRENT.equals(action) || PAUSE_CURRENT.equals(action)) { 84 | activePlayers.add(MainActivity.activePlayer); 85 | controlPlayers(); 86 | } else { 87 | getActivePlayers(); 88 | } 89 | } else { 90 | Utils.debug("App is not currently active"); 91 | } 92 | } 93 | 94 | private void callEnded() { 95 | boolean resume = prefs.getBoolean(SettingsActivity.AFTER_CALL_PREF_KEY, true); 96 | Utils.debug("Call ended, activePlayers:"+activePlayers+", inCall:"+inCall+", resume:"+resume); 97 | if (inCall) { 98 | inCall = false; 99 | if (prefs.getBoolean(SettingsActivity.AFTER_CALL_PREF_KEY, true)) { 100 | controlPlayers(); 101 | } 102 | } 103 | activePlayers.clear(); 104 | } 105 | 106 | private void getActivePlayers() { 107 | rpc.sendMessage("", new String[]{"material-skin", "activeplayers"}, rpcResponse); 108 | } 109 | 110 | private void controlPlayers() { 111 | if (activePlayers.isEmpty()) { 112 | Utils.debug("Control players NO ACTIVE PLAYERS"); 113 | return; 114 | } 115 | String action = prefs.getString(SettingsActivity.ON_CALL_PREF_KEY, DO_NOTHING); 116 | Utils.debug("Control players, action:" + action + ", active:" + activePlayers + ", current:"+MainActivity.activePlayer); 117 | if (MUTE_ALL.equals(action) || PAUSE_ALL.equals(action)) { 118 | for (String id: activePlayers) { 119 | controlPlayer(action, id); 120 | } 121 | } else if (MUTE_CURRENT.equals(action) || PAUSE_CURRENT.equals(action)) { 122 | for (String id: activePlayers) { 123 | Utils.debug(id+"=="+MainActivity.activePlayer+" ? " + id.equals(MainActivity.activePlayer)); 124 | 125 | if (id.equals(MainActivity.activePlayer)) { 126 | Utils.debug("Matched ID"); 127 | controlPlayer(action, id); 128 | break; 129 | } 130 | } 131 | } 132 | } 133 | 134 | private void controlPlayer(String action, String player) { 135 | Utils.debug(action+" on "+player); 136 | if (MUTE_ALL.equals(action) || MUTE_CURRENT.equals(action)) { 137 | rpc.sendMessage(player, new String[]{"mixer", "muting", inCall ? "1" : "0"}); 138 | } else if (PAUSE_ALL.equals(action) || PAUSE_CURRENT.equals(action)) { 139 | rpc.sendMessage(player, new String[]{"pause", inCall ? "1" : "0"}); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/ServerDiscovery.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * Copyright (c) 2020-2023 Craig Drummond 5 | * MIT license. 6 | */ 7 | 8 | package com.craigd.lmsmaterial.app; 9 | 10 | import android.content.Context; 11 | import android.net.wifi.WifiManager; 12 | import android.os.Handler; 13 | import android.os.Looper; 14 | import android.os.Message; 15 | 16 | import androidx.annotation.NonNull; 17 | 18 | import org.json.JSONException; 19 | import org.json.JSONObject; 20 | 21 | import java.io.IOException; 22 | import java.net.DatagramPacket; 23 | import java.net.DatagramSocket; 24 | import java.net.InetAddress; 25 | import java.util.LinkedList; 26 | import java.util.List; 27 | import java.util.Objects; 28 | 29 | public abstract class ServerDiscovery { 30 | private static final int SERVER_DISCOVERY_TIMEOUT = 1500; 31 | 32 | public static class Server implements Comparable { 33 | public static final int DEFAULT_PORT = 9000; 34 | public String ip = ""; 35 | public String name = ""; 36 | public int port = DEFAULT_PORT; 37 | 38 | private static String getString(JSONObject json, String key) { 39 | try { 40 | return json.getString(key); 41 | } catch (JSONException e) { 42 | return ""; 43 | } 44 | } 45 | 46 | private static int getPort(JSONObject json) { 47 | try { 48 | return json.getInt("port"); 49 | } catch (JSONException e) { 50 | return Server.DEFAULT_PORT; 51 | } 52 | } 53 | 54 | public Server(String str) { 55 | Utils.debug("DECODE:"+str); 56 | if (str != null) { 57 | try { 58 | JSONObject json = new JSONObject(str); 59 | ip = getString(json, "ip"); 60 | name = getString(json, "name"); 61 | port = getPort(json); 62 | } catch (JSONException ignored) { 63 | } 64 | } 65 | } 66 | 67 | public Server(String ip, int port, String name) { 68 | this.ip=ip; 69 | this.port=port; 70 | this.name=name; 71 | } 72 | 73 | public Server(DatagramPacket pkt) { 74 | ip = pkt.getAddress().getHostAddress(); 75 | 76 | // Try to get name of server for packet 77 | int pktLen = pkt.getLength(); 78 | byte[] bytes = pkt.getData(); 79 | 80 | // Look for NAME: in list of key:value pairs 81 | for(int i=1; i < pktLen; ) { 82 | if (i + 5 > pktLen) { 83 | break; 84 | } 85 | 86 | // Extract 4 bytes 87 | String key = new String(bytes, i, 4); 88 | i += 4; 89 | 90 | int valueLen = bytes[i++] & 0xFF; 91 | if (i + valueLen > pktLen) { 92 | break; 93 | } 94 | 95 | if (key.equals("NAME")) { 96 | name = new String(bytes, i, valueLen); 97 | Utils.debug("Name:"+name); 98 | } else if (key.equals("JSON")) { 99 | try { 100 | port = Integer.parseInt(new String(bytes, i, valueLen)); 101 | Utils.debug("Port:"+port); 102 | } catch (NumberFormatException ignored) { 103 | } 104 | } 105 | i += valueLen; 106 | } 107 | } 108 | 109 | public boolean isEmpty() { 110 | return null==ip || ip.isEmpty(); 111 | } 112 | 113 | @Override 114 | public int compareTo(@NonNull Server o) { 115 | return null==ip ? (o.ip==null ? 0 : -1) : ip.compareTo(o.ip); 116 | } 117 | 118 | public boolean equals(Server o) { 119 | return Objects.equals(ip, o.ip); 120 | } 121 | 122 | public String describe() { 123 | if (null==name || name.isEmpty()) { 124 | return address(); 125 | } 126 | return name+" ("+address()+")"; 127 | } 128 | 129 | public String address() { 130 | return ip + (DEFAULT_PORT==port ? "" : (":"+port)); 131 | } 132 | 133 | public String encode() { 134 | try { 135 | JSONObject json = new JSONObject(); 136 | json.put("ip", ip); 137 | json.put("name", name); 138 | json.put("port", port); 139 | return json.toString(0); 140 | } catch (JSONException e) { 141 | return ip; 142 | } 143 | } 144 | } 145 | 146 | class DiscoveryRunnable implements Runnable { 147 | private volatile boolean active = false; 148 | private final WifiManager wifiManager; 149 | private final List servers = new LinkedList<>(); 150 | 151 | DiscoveryRunnable(WifiManager wifiManager) { 152 | this.wifiManager = wifiManager; 153 | } 154 | 155 | @Override 156 | public void run() { 157 | Utils.debug("Discover LMS servers"); 158 | 159 | active = true; 160 | WifiManager.WifiLock wifiLock; 161 | DatagramSocket socket = null; 162 | wifiLock = wifiManager.createWifiLock(Utils.LOG_TAG); 163 | wifiLock.acquire(); 164 | 165 | try { 166 | InetAddress broadcastAddress = InetAddress.getByName("255.255.255.255"); 167 | socket = new DatagramSocket(); 168 | byte[] req = { 'e', 'I', 'P', 'A', 'D', 0, 'N', 'A', 'M', 'E', 0, 'J', 'S', 'O', 'N', 0 }; 169 | DatagramPacket reqPkt = new DatagramPacket(req, req.length, broadcastAddress, 3483); 170 | byte[] resp = new byte[256]; 171 | DatagramPacket respPkt = new DatagramPacket(resp, resp.length); 172 | 173 | socket.setSoTimeout(SERVER_DISCOVERY_TIMEOUT); 174 | socket.send(reqPkt); 175 | for (;;) { 176 | try { 177 | socket.receive(respPkt); 178 | if (resp[0]==(byte)'E') { 179 | Server server = new Server(respPkt); 180 | if (!servers.contains(server)) { 181 | servers.add(server); 182 | if (!discoverAll) { 183 | break; // Stop at first for now... 184 | } 185 | } 186 | } 187 | } catch (IOException e) { 188 | break; 189 | } 190 | } 191 | 192 | } catch (Exception ignored) { 193 | } finally { 194 | if (socket != null) { 195 | socket.close(); 196 | } 197 | 198 | Utils.verbose("Scanning complete, unlocking WiFi"); 199 | wifiLock.release(); 200 | } 201 | 202 | handler.sendMessage(new Message()); 203 | active = false; 204 | } 205 | 206 | public boolean isActive() { 207 | return active; 208 | } 209 | } 210 | 211 | final Context context; 212 | private final boolean discoverAll; 213 | private final Handler handler; 214 | private DiscoveryRunnable runnable; 215 | 216 | ServerDiscovery(Context context, boolean discoverAll) { 217 | this.context = context; 218 | this.discoverAll = discoverAll; 219 | handler = new Handler(Looper.getMainLooper()) { 220 | @Override 221 | public void handleMessage(@NonNull Message unused) { 222 | discoveryFinished(runnable.servers); 223 | } 224 | }; 225 | } 226 | 227 | public void discover() { 228 | if (runnable!=null && runnable.isActive()) { 229 | return; 230 | } 231 | runnable = new DiscoveryRunnable((WifiManager) context.getApplicationContext().getSystemService(Context.WIFI_SERVICE)); 232 | Thread thread = new Thread(runnable); 233 | thread.start(); 234 | } 235 | 236 | protected abstract void discoveryFinished(List servers); 237 | } 238 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/TermuxResultsService.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * Copyright (c) 2020-2023 Craig Drummond 5 | * MIT license. 6 | */ 7 | 8 | package com.craigd.lmsmaterial.app; 9 | 10 | import android.annotation.SuppressLint; 11 | import android.app.IntentService; 12 | import android.content.Intent; 13 | import android.os.Bundle; 14 | 15 | import androidx.preference.PreferenceManager; 16 | 17 | public class TermuxResultsService extends IntentService { 18 | public static final String EXTRA_EXECUTION_ID = "execution_id"; 19 | private static int EXECUTION_ID = 1000; 20 | 21 | public static synchronized int getNextExecutionId() { 22 | EXECUTION_ID++; 23 | return EXECUTION_ID; 24 | } 25 | 26 | public TermuxResultsService() { 27 | super("TermuxResultsService"); 28 | } 29 | 30 | @SuppressLint("SdCardPath") 31 | @Override 32 | protected void onHandleIntent(Intent intent) { 33 | if (intent == null) { 34 | return; 35 | } 36 | 37 | Bundle resultBundle = intent.getBundleExtra("result"); 38 | if (resultBundle == null) { 39 | return; 40 | } 41 | 42 | if (intent.getIntExtra(EXTRA_EXECUTION_ID, 0)!=EXECUTION_ID) { 43 | return; 44 | } 45 | 46 | String stdout = resultBundle.getString("stdout", ""); 47 | if (stdout.contains("/data/data/com.termux/files/usr/bin/squeezelite")) { 48 | Utils.debug("Squeezelite is already running"); 49 | } else { 50 | new LocalPlayer(PreferenceManager.getDefaultSharedPreferences(getApplicationContext()), getApplicationContext()).startTermuxSqueezeLite(); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/UrlHandler.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * Copyright (c) 2020-2023 Craig Drummond 5 | * MIT license. 6 | */ 7 | 8 | package com.craigd.lmsmaterial.app; 9 | 10 | import android.app.Dialog; 11 | import android.view.LayoutInflater; 12 | import android.view.View; 13 | import android.widget.AdapterView; 14 | import android.widget.ArrayAdapter; 15 | import android.widget.Button; 16 | import android.widget.Spinner; 17 | 18 | import androidx.appcompat.app.AlertDialog; 19 | 20 | import com.android.volley.Response; 21 | 22 | import org.json.JSONArray; 23 | import org.json.JSONException; 24 | import org.json.JSONObject; 25 | 26 | import java.util.ArrayList; 27 | import java.util.Collections; 28 | import java.util.LinkedList; 29 | import java.util.List; 30 | 31 | public class UrlHandler { 32 | private final MainActivity mainActivity; 33 | private JsonRpc rpc; 34 | private String handlingUrl; 35 | private Dialog dialog; 36 | private Spinner playerName; 37 | private final List playerList = new LinkedList<>(); 38 | private int chosenPlayer = 0; 39 | 40 | private static class Player implements Comparable { 41 | public Player(String name, String id) { 42 | this.name = name; 43 | this.id = id; 44 | } 45 | public String name; 46 | public String id; 47 | 48 | @Override 49 | public int compareTo(Object o) { 50 | return name.compareToIgnoreCase(((Player)o).name); 51 | } 52 | } 53 | 54 | private final Response.Listener serverStatusResponse = new Response.Listener () { 55 | @Override 56 | public void onResponse(JSONObject response) { 57 | playerList.clear(); 58 | try { 59 | Utils.debug("RESP" + response.toString(4)); 60 | JSONObject result = response.getJSONObject("result"); 61 | if (result.has("players_loop")) { 62 | JSONArray players = result.getJSONArray("players_loop"); 63 | if (players.length() > 0) { 64 | for (int i = 0; i < players.length(); ++i) { 65 | JSONObject obj = players.getJSONObject(i); 66 | playerList.add(new Player(obj.getString("name"), obj.getString("playerid"))); 67 | } 68 | Utils.debug("RPC response, numPlayers:" + playerList.size()); 69 | } 70 | } 71 | } catch (JSONException e) { 72 | Utils.error( "Failed to parse response", e); 73 | } 74 | if (playerList.isEmpty()) { 75 | return; 76 | } 77 | Collections.sort(playerList); 78 | 79 | // Create dialog 80 | if (null==dialog) { 81 | AlertDialog.Builder builder = new AlertDialog.Builder(mainActivity); 82 | LayoutInflater inflater = mainActivity.getLayoutInflater(); 83 | View view = inflater.inflate(R.layout.url_handler, null); 84 | 85 | builder.setView(view) 86 | .setNegativeButton(R.string.cancel, (dialog, id) -> dialog.dismiss()); 87 | dialog = builder.create(); 88 | playerName = view.findViewById(R.id.player_name); 89 | 90 | Button play_now = view.findViewById(R.id.play_now_button); 91 | Button play_next = view.findViewById(R.id.play_next_button); 92 | Button insert = view.findViewById(R.id.insert_button); 93 | 94 | play_now.setOnClickListener(view1 -> { 95 | dialog.dismiss(); 96 | addUrlToPlayer("play"); 97 | }); 98 | play_next.setOnClickListener(view12 -> { 99 | dialog.dismiss(); 100 | addUrlToPlayer("add"); 101 | }); 102 | insert.setOnClickListener(view13 -> { 103 | dialog.dismiss(); 104 | addUrlToPlayer("insert"); 105 | }); 106 | playerName.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() { 107 | @Override 108 | public void onItemSelected(AdapterView parent, View view, int position, long id) { 109 | chosenPlayer = position; 110 | } 111 | 112 | @Override 113 | public void onNothingSelected(AdapterView parent) { 114 | } 115 | }); 116 | } 117 | 118 | // Initialise items 119 | ArrayList player_names = new ArrayList<>(); 120 | for (Player player: playerList) { 121 | player_names.add(player.name); 122 | } 123 | ArrayAdapter adapter = new ArrayAdapter<>(mainActivity, android.R.layout.simple_spinner_item, player_names); 124 | adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item); 125 | playerName.setAdapter(adapter); 126 | 127 | chosenPlayer = 0; 128 | if (null!=MainActivity.activePlayer) { 129 | for (int i=0; i addActionResponse = new Response.Listener () { 144 | @Override 145 | public void onResponse(JSONObject response) { 146 | if (chosenPlayer>=0 && chosenPlayer=playerList.size()) { 167 | return; 168 | } 169 | Player player = playerList.get(chosenPlayer); 170 | Utils.debug(action+": "+handlingUrl+", to: "+player.name); 171 | rpc.sendMessage(player.id, new String[]{"playlist", action, handlingUrl}, addActionResponse); 172 | handlingUrl = null; 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/Utils.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * Copyright (c) 2020-2024 Craig Drummond 5 | * MIT license. 6 | */ 7 | 8 | package com.craigd.lmsmaterial.app; 9 | import android.Manifest; 10 | import android.annotation.SuppressLint; 11 | import android.app.Activity; 12 | import android.app.NotificationChannel; 13 | import android.app.NotificationManager; 14 | import android.content.Context; 15 | import android.content.pm.PackageManager; 16 | import android.content.res.Resources; 17 | import android.graphics.Insets; 18 | import android.graphics.Rect; 19 | import android.net.ConnectivityManager; 20 | import android.net.NetworkInfo; 21 | import android.os.Build; 22 | import android.util.DisplayMetrics; 23 | import android.util.Log; 24 | import android.view.DisplayCutout; 25 | import android.view.WindowInsets; 26 | 27 | import androidx.core.app.ActivityCompat; 28 | import androidx.core.app.NotificationManagerCompat; 29 | 30 | import java.io.UnsupportedEncodingException; 31 | import java.net.URLEncoder; 32 | import java.util.List; 33 | import java.util.concurrent.TimeUnit; 34 | 35 | public class Utils { 36 | public static final String LOG_TAG = "LMS"; 37 | 38 | public static boolean isNetworkConnected(Context context) { 39 | ConnectivityManager connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); 40 | NetworkInfo info = connectivityManager.getActiveNetworkInfo(); 41 | return null!=info && info.isConnected(); 42 | } 43 | 44 | public static float convertPixelsToDp(float px, Context context){ 45 | return px / ((float) context.getResources().getDisplayMetrics().densityDpi / DisplayMetrics.DENSITY_DEFAULT); 46 | } 47 | 48 | public static boolean cutoutTopLeft(Activity activity) { 49 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { 50 | DisplayCutout displayCutout = activity.getWindowManager().getDefaultDisplay().getCutout(); 51 | if (null != displayCutout) { 52 | List rects = displayCutout.getBoundingRects(); 53 | for (Rect rect : rects) { 54 | if (rect.left >= 0 && rect.left <= 10 && rect.width() > 100 && rect.width()<300) { 55 | return true; 56 | } 57 | } 58 | } 59 | } 60 | return false; 61 | } 62 | 63 | public static boolean usingGestureNavigation(Activity activity) { 64 | Resources resources = activity.getResources(); 65 | @SuppressLint("DiscouragedApi") int resourceId = resources.getIdentifier("config_navBarInteractionMode", "integer", "android"); 66 | if (resourceId > 0) { 67 | return 2==resources.getInteger(resourceId); 68 | } 69 | return false; 70 | } 71 | 72 | static int getTopPadding(Activity activity) { 73 | int def = 26; 74 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 75 | WindowInsets wi = activity.getWindowManager().getCurrentWindowMetrics().getWindowInsets(); 76 | Insets i = wi.getInsets(WindowInsets.Type.systemBars()); 77 | return Math.max((int)Math.ceil(convertPixelsToDp(i.top, activity)), def); 78 | } 79 | return def; 80 | } 81 | 82 | static int getBottomPadding(Activity activity) { 83 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { 84 | WindowInsets wi = activity.getWindowManager().getCurrentWindowMetrics().getWindowInsets(); 85 | Insets i = wi.getInsets(WindowInsets.Type.navigationBars()); 86 | int val = (int)Math.ceil(convertPixelsToDp(i.bottom, activity)); 87 | Utils.debug("inset:" + val); 88 | return val>8 ? Math.max(val, 14) : val; 89 | } 90 | return usingGestureNavigation(activity) ? 14 : 40; 91 | } 92 | 93 | static public boolean notificationAllowed(Context context, String channelId) { 94 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { 95 | debug("Check if notif permission granted"); 96 | if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { 97 | debug("No notif permission"); 98 | return false; 99 | } 100 | } 101 | if (!NotificationManagerCompat.from(context).areNotificationsEnabled()) { 102 | debug("Notifs are disabled"); 103 | return false; 104 | } 105 | if (channelId!=null && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { 106 | NotificationManager mgr = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); 107 | NotificationChannel channel = mgr.getNotificationChannel(channelId); 108 | if (null!=channel) { 109 | debug("Channel " + channelId + " importance " + channel.getImportance()); 110 | return channel.getImportance() != NotificationManager.IMPORTANCE_NONE; 111 | } 112 | } 113 | debug("Notifs are allowed"); 114 | return true; 115 | } 116 | 117 | public static boolean isEmpty(String str) { 118 | return null==str || str.isEmpty(); 119 | } 120 | 121 | public static String encodeURIComponent(String str) { 122 | try { 123 | return URLEncoder.encode(str, "UTF-8") 124 | .replaceAll("\\+", "%20") 125 | .replaceAll("\\%21", "!") 126 | .replaceAll("\\%27", "'") 127 | .replaceAll("\\%28", "(") 128 | .replaceAll("\\%29", ")") 129 | .replaceAll("\\%7E", "~"); 130 | } catch (UnsupportedEncodingException ignored) { 131 | } 132 | return str; 133 | } 134 | 135 | public static String timeStr(long ms) { 136 | return String.format("%02d:%02d", 137 | TimeUnit.MILLISECONDS.toMinutes(ms) - 138 | TimeUnit.HOURS.toMinutes(TimeUnit.MILLISECONDS.toHours(ms)), 139 | TimeUnit.MILLISECONDS.toSeconds(ms) - 140 | TimeUnit.MINUTES.toSeconds(TimeUnit.MILLISECONDS.toMinutes(ms))); 141 | } 142 | 143 | private static String logPrefix() { 144 | StackTraceElement st[] = Thread.currentThread().getStackTrace(); 145 | if (null!=st && st.length>4) { 146 | // Remove com.craigd.lmsmaterial.app. 147 | return "["+st[4].getClassName().substring(27)+"."+st[4].getMethodName()+"] "; 148 | } 149 | return ""; 150 | } 151 | 152 | public static void verbose(String message) { 153 | if (BuildConfig.DEBUG) { 154 | Log.v(LOG_TAG, logPrefix() + message); 155 | } 156 | } 157 | 158 | public static void debug(String message) { 159 | if (BuildConfig.DEBUG) { 160 | Log.d(LOG_TAG, logPrefix() + message); 161 | } 162 | } 163 | 164 | public static void info(String message) { 165 | if (BuildConfig.DEBUG) { 166 | Log.i(LOG_TAG, logPrefix() + message); 167 | } 168 | } 169 | 170 | public static void warn(String message) { 171 | if (BuildConfig.DEBUG) { 172 | Log.w(LOG_TAG, logPrefix() + message); 173 | } 174 | } 175 | 176 | public static void warn(String message, Throwable t) { 177 | if (BuildConfig.DEBUG) { 178 | Log.w(LOG_TAG, logPrefix() + message, t); 179 | } 180 | } 181 | 182 | public static void error(String message) { 183 | if (BuildConfig.DEBUG) { 184 | Log.e(LOG_TAG, logPrefix() + message); 185 | } 186 | } 187 | 188 | public static void error(String message, Throwable t) { 189 | if (BuildConfig.DEBUG) { 190 | Log.e(LOG_TAG, logPrefix() + message, t); 191 | } 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/cometd/BayeuxExtension.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * NOTE: This file copied from https://github.com/kaaholst/android-squeezer 5 | * 6 | * Apache-2.0 license 7 | */ 8 | 9 | package com.craigd.lmsmaterial.app.cometd; 10 | 11 | import com.craigd.lmsmaterial.app.Utils; 12 | 13 | import org.cometd.bayeux.Channel; 14 | import org.cometd.bayeux.Message; 15 | import org.cometd.bayeux.client.ClientSession; 16 | 17 | public class BayeuxExtension extends ClientSession.Extension.Adapter { 18 | @Override 19 | public boolean sendMeta(ClientSession session, Message.Mutable message) { 20 | if (Channel.META_HANDSHAKE.equals(message.getChannel())) { 21 | if (message.getClientId() != null) { 22 | Utils.verbose("Reset client id"); 23 | message.setClientId(null); 24 | } 25 | } 26 | return true; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/cometd/CometClient.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * NOTE: This file is *very* much inspired from https://github.com/kaaholst/android-squeezer 5 | * 6 | * Apache-2.0 license 7 | */ 8 | 9 | package com.craigd.lmsmaterial.app.cometd; 10 | 11 | import static com.craigd.lmsmaterial.app.MainActivity.LMS_PASSWORD_KEY; 12 | import static com.craigd.lmsmaterial.app.MainActivity.LMS_USERNAME_KEY; 13 | 14 | import android.content.SharedPreferences; 15 | import android.os.Handler; 16 | import android.os.HandlerThread; 17 | import android.os.Looper; 18 | import android.os.SystemClock; 19 | 20 | import androidx.preference.PreferenceManager; 21 | 22 | import com.android.volley.Response; 23 | import com.craigd.lmsmaterial.app.ControlService; 24 | import com.craigd.lmsmaterial.app.JsonRpc; 25 | import com.craigd.lmsmaterial.app.MainActivity; 26 | import com.craigd.lmsmaterial.app.ServerDiscovery; 27 | import com.craigd.lmsmaterial.app.SettingsActivity; 28 | import com.craigd.lmsmaterial.app.Utils; 29 | 30 | import org.cometd.bayeux.Channel; 31 | import org.cometd.bayeux.Message; 32 | import org.cometd.bayeux.client.ClientSessionChannel; 33 | import org.cometd.client.transport.ClientTransport; 34 | import org.eclipse.jetty.client.HttpClient; 35 | import org.eclipse.jetty.http.HttpHeader; 36 | import org.eclipse.jetty.util.B64Code; 37 | import org.json.JSONArray; 38 | import org.json.JSONException; 39 | import org.json.JSONObject; 40 | 41 | import java.net.URL; 42 | import java.util.ArrayList; 43 | import java.util.Arrays; 44 | import java.util.HashMap; 45 | import java.util.List; 46 | import java.util.Map; 47 | import java.util.Objects; 48 | 49 | 50 | public class CometClient { 51 | private final SharedPreferences prefs; 52 | final ConnectionState connectionState; 53 | private SlimClient bayeuxClient; 54 | private String currentPlayer = null; 55 | private String subscribedPlayer = null; 56 | // Keep server details so that we can detect if changed 57 | private String serverAddress = ""; 58 | private int serverPort = 9000; 59 | private String serverUser = ""; 60 | private String serverPass = ""; 61 | private final Handler backgroundHandler; 62 | private ControlService service; 63 | private JsonRpc rpc; 64 | private Response.Listener rpcResponse; 65 | private int handShakeFailures = 0; 66 | 67 | private static final int MAX_HANDSHAKE_FAILURES = 5; 68 | private static final String DEFAULT_RADIO_COVER = "/material/html/images/noradio.png"; 69 | private static final String DEFAULT_COVER = "/material/html/images/nocover.png"; 70 | private static final String DEFAULT_WORKS_COVER = "/material/html/images/nowork.png"; 71 | private static final String RANDOMPLAY_COVER = "/material/html/images/randomplay.png"; 72 | private static final String IMAGE_SIZE = "_600x600_f"; 73 | private static final String PLAYER_STATUS_TAGS = "tags:acdlKN"; 74 | private static final int HANDSHAKE_TIMEOUT = 4*1000; 75 | private static final int MSG_HANDSHAKE_TIMEOUT = 1; 76 | private static final int MSG_DISCONNECT = 2; 77 | private static final int MSG_RECONNECT = 3; 78 | private static final int MSG_SET_PLAYER = 4; 79 | private static final int MSG_PUBLISH = 5; 80 | 81 | private class PublishListener implements ClientSessionChannel.MessageListener { 82 | @Override 83 | public void onMessage(ClientSessionChannel channel, Message message) { 84 | if (!message.isSuccessful()) { 85 | if (Message.RECONNECT_HANDSHAKE_VALUE.equals(getAdviceAction(message.getAdvice()))) { 86 | Utils.info("rehandshake"); 87 | bayeuxClient.rehandshake(); 88 | } else { 89 | Map failure = getRecord(message, "failure"); 90 | Exception exception = (failure != null) ? (Exception) failure.get("exception") : null; 91 | Utils.warn(channel + ": " + message.getJSON(), exception); 92 | } 93 | } 94 | } 95 | } 96 | 97 | private static class PublishMessage { 98 | final Object request; 99 | final String channel; 100 | final String responseChannel; 101 | final PublishListener publishListener; 102 | 103 | private PublishMessage(Object request, String channel, String responseChannel, PublishListener publishListener) { 104 | this.request = request; 105 | this.channel = channel; 106 | this.responseChannel = responseChannel; 107 | this.publishListener = publishListener; 108 | } 109 | } 110 | 111 | private class MessageHandler extends Handler { 112 | MessageHandler(Looper looper) { 113 | super(looper); 114 | } 115 | 116 | @Override 117 | public void handleMessage(android.os.Message msg) { 118 | Utils.debug(""+msg.what); 119 | switch (msg.what) { 120 | case MSG_HANDSHAKE_TIMEOUT: 121 | Utils.warn("Handshake timeout: " + connectionState); 122 | disconnectFromServer(); 123 | break; 124 | case MSG_DISCONNECT: 125 | removeCallbacksAndMessages(null); 126 | disconnectFromServer(); 127 | break; 128 | case MSG_RECONNECT: 129 | disconnectFromServer(); 130 | connect(); 131 | break; 132 | case MSG_SET_PLAYER: 133 | subscribeToPlayer((String)msg.obj); 134 | break; 135 | case MSG_PUBLISH: { 136 | PublishMessage message = (PublishMessage) msg.obj; 137 | doPublishMessage(message.request, message.channel, message.responseChannel, message.publishListener); 138 | break; 139 | } 140 | default: 141 | break; 142 | } 143 | } 144 | } 145 | 146 | public CometClient(ControlService service) { 147 | this.service = service; 148 | prefs = PreferenceManager.getDefaultSharedPreferences(service.getApplicationContext()); 149 | connectionState = new ConnectionState(); 150 | HandlerThread handlerThread = new HandlerThread(CometClient.class.getSimpleName()); 151 | handlerThread.start(); 152 | backgroundHandler = new MessageHandler(handlerThread.getLooper()); 153 | } 154 | 155 | public synchronized void reconnectIfChanged() { 156 | Utils.debug(""); 157 | ServerDiscovery.Server server = new ServerDiscovery.Server(prefs.getString(SettingsActivity.SERVER_PREF_KEY, null)); 158 | boolean changed = !serverUser.equals(prefs.getString(LMS_USERNAME_KEY, "")) || 159 | !serverPass.equals(prefs.getString(LMS_PASSWORD_KEY, "")) || 160 | serverPort!=server.port || !serverAddress.equals(server.ip); 161 | if (changed) { 162 | disconnect(true); 163 | } 164 | } 165 | 166 | public synchronized boolean isConnected() { 167 | return connectionState.isConnected() && null!=bayeuxClient; 168 | } 169 | 170 | public synchronized void connect() { 171 | Utils.debug(""); 172 | connectionState.setConnectionState(ConnectionState.State.CONNECTION_STARTED); 173 | backgroundHandler.post(() -> { 174 | ServerDiscovery.Server server = new ServerDiscovery.Server(prefs.getString(SettingsActivity.SERVER_PREF_KEY, null)); 175 | if (null == server.ip) { 176 | connectionState.setConnectionError(ConnectionState.Error.INVALID_URL); 177 | return; 178 | } 179 | 180 | final HttpClient httpClient = new HttpClient(); 181 | try { 182 | httpClient.start(); 183 | } catch (Exception e) { 184 | connectionState.setConnectionError(ConnectionState.Error.START_CLIENT_ERROR); 185 | return; 186 | } 187 | 188 | serverAddress = server.ip; 189 | serverPort = server.port; 190 | String url = "http://"+serverAddress+":"+serverPort + "/cometd"; 191 | Utils.debug("CometD URL: " + url); 192 | ClientTransport clientTransport = new HttpStreamingTransport(url, null, httpClient) { 193 | @Override 194 | protected void customize(org.eclipse.jetty.client.api.Request request) { 195 | serverUser = prefs.getString(LMS_USERNAME_KEY, ""); 196 | serverPass= prefs.getString(LMS_PASSWORD_KEY, ""); 197 | 198 | if (!serverUser.isEmpty() && !serverPass.isEmpty()) { 199 | request.header(HttpHeader.AUTHORIZATION, "Basic " + B64Code.encode(serverUser + ":" + serverPass)); 200 | } 201 | } 202 | }; 203 | bayeuxClient = new SlimClient(connectionState, url, clientTransport); 204 | bayeuxClient.addExtension(new BayeuxExtension()); 205 | backgroundHandler.sendEmptyMessageDelayed(MSG_HANDSHAKE_TIMEOUT, HANDSHAKE_TIMEOUT); 206 | bayeuxClient.getChannel(Channel.META_HANDSHAKE).addListener((ClientSessionChannel.MessageListener) (channel, message) -> { 207 | handShakeFailures = message.isSuccessful() ? 0 : (handShakeFailures+1); 208 | Utils.debug("Handshake OK: " + message.isSuccessful() + ", canRehandshake: " + connectionState.canRehandshake() + ", failures:" +handShakeFailures); 209 | if (message.isSuccessful()) { 210 | onConnected(); 211 | } else if (handShakeFailures>=MAX_HANDSHAKE_FAILURES && Utils.isNetworkConnected(service)) { 212 | Utils.error("Too many handshake errors, aborting"); 213 | handShakeFailures = 0; 214 | try { 215 | clientTransport.abort(); 216 | try { 217 | httpClient.stop(); 218 | } catch (Exception e) { 219 | Utils.error("Failed to stop HTTP client", e); 220 | } 221 | bayeuxClient.stop(); 222 | bayeuxClient = null; 223 | if (!MainActivity.isActive() && !SettingsActivity.isVisible()) { 224 | Utils.debug("UI is not visible, so terminate"); 225 | service.quit(); 226 | } 227 | } catch (Exception e) { 228 | Utils.error("Aborting", e); 229 | } 230 | connectionState.setConnectionState(ConnectionState.State.DISCONNECTED); 231 | } else if (!connectionState.canRehandshake()) { 232 | handShakeFailures = 0; 233 | Map failure = getRecord(message, "failure"); 234 | Message failedMessage = (failure != null) ? (Message) failure.get("message") : message; 235 | // Advices are handled internally by the bayeux protocol, so skip these here 236 | if (failedMessage != null && getAdviceAction(failedMessage.getAdvice()) == null) { 237 | Utils.warn("Unsuccessful message on handshake channel: " + message.getJSON()); 238 | disconnect(); 239 | } 240 | } 241 | }); 242 | bayeuxClient.getChannel(Channel.META_CONNECT).addListener((ClientSessionChannel.MessageListener) (channel, message) -> { 243 | Utils.debug("Connect OK? " + message.isSuccessful()); 244 | // Advices are handled internally by the bayeux protocol, so skip these here 245 | if (!message.isSuccessful() && (getAdviceAction(message.getAdvice()) == null)) { 246 | Utils.warn("Unsuccessful message on connect channel: " + message.getJSON()); 247 | disconnect(); 248 | } 249 | }); 250 | bayeuxClient.handshake(); 251 | }); 252 | } 253 | 254 | @SuppressWarnings("unchecked") 255 | private static Map getRecord(Map record, String name) { 256 | Object rec = record.get(name); 257 | return rec instanceof Map ? (Map) rec : null; 258 | } 259 | 260 | private static String getAdviceAction(Map advice) { 261 | if (advice != null && advice.containsKey(Message.RECONNECT_FIELD)) { 262 | return (String) advice.get(Message.RECONNECT_FIELD); 263 | } 264 | return null; 265 | } 266 | 267 | public synchronized void setPlayer(String id) { 268 | currentPlayer = id; 269 | if (bayeuxClient != null) { 270 | backgroundHandler.sendMessage(android.os.Message.obtain(null, MSG_SET_PLAYER, id)); 271 | } 272 | } 273 | 274 | private synchronized void subscribeToPlayer(String id) { 275 | currentPlayer = id; 276 | if (null==id) { 277 | unsubscribePlayer(subscribedPlayer); 278 | } else if (!id.equals(subscribedPlayer)) { 279 | unsubscribePlayer(subscribedPlayer); 280 | subscribePlayer(id); 281 | } 282 | } 283 | 284 | public void disconnect() { 285 | disconnect(false); 286 | } 287 | 288 | private void disconnect(boolean andReconnect) { 289 | Utils.debug("connected:"+connectionState.isConnected()); 290 | if (bayeuxClient != null && connectionState.isConnected()) { 291 | backgroundHandler.sendEmptyMessage(andReconnect ? MSG_RECONNECT : MSG_DISCONNECT); 292 | } 293 | connectionState.setConnectionState(ConnectionState.State.DISCONNECTED); 294 | } 295 | 296 | private synchronized void disconnectFromServer() { 297 | if (bayeuxClient != null) { 298 | String[] channels = new String[]{Channel.META_HANDSHAKE, Channel.META_CONNECT}; 299 | for (String channelId : channels) { 300 | ClientSessionChannel channel = bayeuxClient.getChannel(channelId); 301 | for (ClientSessionChannel.ClientSessionChannelListener listener : channel.getListeners()) { 302 | channel.removeListener(listener); 303 | } 304 | channel.unsubscribe(); 305 | } 306 | bayeuxClient.disconnect(); 307 | bayeuxClient = null; 308 | } 309 | subscribedPlayer = null; 310 | } 311 | 312 | private synchronized void onConnected() { 313 | Utils.debug("currentPlayer:"+currentPlayer); 314 | subscribedPlayer = null; 315 | connectionState.setConnectionState(ConnectionState.State.CONNECTION_COMPLETED); 316 | bayeuxClient.getChannel("/"+bayeuxClient.getId() + "/slim/playerstatus/*").subscribe(this::handlePlayerStatus); 317 | subscribeToPlayer(currentPlayer); 318 | backgroundHandler.removeMessages(MSG_HANDSHAKE_TIMEOUT); 319 | } 320 | 321 | private void publishMessage(Object request, final String channel, final String responseChannel, final PublishListener publishListener) { 322 | // Make sure all requests are done in the handler thread 323 | if (backgroundHandler.getLooper() == Looper.myLooper()) { 324 | doPublishMessage(request, channel, responseChannel, publishListener); 325 | } else { 326 | PublishMessage publishMessage = new PublishMessage(request, channel, responseChannel, publishListener); 327 | android.os.Message message = backgroundHandler.obtainMessage(MSG_PUBLISH, publishMessage); 328 | backgroundHandler.sendMessage(message); 329 | } 330 | } 331 | 332 | private void doPublishMessage(Object request, String channel, String responseChannel, PublishListener publishListener) { 333 | Map data = new HashMap<>(); 334 | if (request != null) { 335 | data.put("request", request); 336 | data.put("response", responseChannel); 337 | } else { 338 | data.put("unsubscribe", responseChannel); 339 | } 340 | bayeuxClient.getChannel(channel).publish(data, publishListener); 341 | } 342 | 343 | private void subscribePlayer(String id) { 344 | Utils.debug("ID:"+id+", connected:"+connectionState.isConnected()); 345 | if (null!=id && !id.isEmpty() && connectionState.isConnected() && !id.equals(subscribedPlayer)) { 346 | List req = new ArrayList<>(); 347 | List params = new ArrayList<>(); 348 | params.add("status"); 349 | params.add("-"); 350 | params.add("1"); 351 | params.add("subscribe:0"); 352 | params.add(PLAYER_STATUS_TAGS); 353 | req.add(id); 354 | req.add(params); 355 | publishMessage(req, "/slim/subscribe", "/" + bayeuxClient.getId() + "/slim/playerstatus/" + id, new PublishListener() { 356 | @Override 357 | public void onMessage(ClientSessionChannel channel, Message message) { 358 | super.onMessage(channel, message); 359 | if (message.isSuccessful()) { 360 | subscribedPlayer = id; 361 | getPlayerStatus(subscribedPlayer); 362 | } 363 | } 364 | }); 365 | } 366 | } 367 | 368 | private void unsubscribePlayer(String id) { 369 | Utils.debug("ID:"+id+", connected:"+connectionState.isConnected()); 370 | if (null!=id && !id.isEmpty() && connectionState.isConnected()) { 371 | if (id.equals(subscribedPlayer)) { 372 | publishMessage(null, "/slim/subscribe", "/" + bayeuxClient.getId() + "/slim/playerstatus/" + id, new PublishListener() { 373 | @Override 374 | public void onMessage(ClientSessionChannel channel, Message message) { 375 | super.onMessage(channel, message); 376 | if (message.isSuccessful()) { 377 | subscribedPlayer = null; 378 | } 379 | } 380 | }); 381 | } else { 382 | subscribedPlayer = null; 383 | } 384 | } 385 | } 386 | 387 | private void sendMessage(String id, String[] command) { 388 | if (null==rpc) { 389 | rpc = new JsonRpc(service); 390 | rpcResponse = response -> { 391 | try { 392 | if ("status".equals(command[0])) { 393 | handlePlayerStatus(id, response.getJSONObject("result")); 394 | } 395 | } catch (JSONException e) { 396 | Utils.error("RPC failed - " + Arrays.toString(command), e); 397 | } 398 | }; 399 | } 400 | rpc.sendMessage(id, command, rpcResponse); 401 | } 402 | 403 | public void getPlayerStatus(String id) { 404 | sendMessage(id, new String[]{"status", "-", "1", PLAYER_STATUS_TAGS}); 405 | } 406 | 407 | private String resolveImageUrl(String image) { 408 | if ((image.contains("http://") || image.contains("https://")) && !(image.startsWith("/imageproxy") || image.startsWith("imageproxy"))) { 409 | try { 410 | URL url = new URL(image); 411 | if (url.getHost().startsWith("192.168.") || url.getHost().startsWith("127.") || url.getHost().endsWith(".local")) { 412 | return image; 413 | } 414 | return "/imageproxy/" + Utils.encodeURIComponent(image) + "/image" + IMAGE_SIZE; 415 | } catch (Exception ignored) { 416 | return image; 417 | } 418 | } 419 | 420 | switch (image) { 421 | case "html/images/cover.png": 422 | return DEFAULT_COVER; 423 | case "html/images/radio.png": 424 | return DEFAULT_RADIO_COVER; 425 | case "html/images/works.png": 426 | return DEFAULT_WORKS_COVER; 427 | case "plugins/RandomPlay/html/images/icon.png": 428 | return RANDOMPLAY_COVER; 429 | } 430 | int idx = image.lastIndexOf(".png"); 431 | if (idx < 0) { 432 | idx = image.lastIndexOf(".jpg"); 433 | } 434 | if (idx<0 && image.matches("^[0-9a-fA-F]+$")) { 435 | image="music/"+image+"/cover"+IMAGE_SIZE; 436 | } else if (idx>0) { 437 | if ((image.startsWith("plugins/") || image.startsWith("/plugins/")) && image.indexOf("/html/images/")>0) { 438 | return image; 439 | } 440 | image = image.substring(0, idx)+IMAGE_SIZE+image.substring(idx); 441 | } 442 | return image.startsWith("/") ? image : ("/"+image); 443 | } 444 | 445 | private String coverUrl(String path) { 446 | return path.startsWith("http") ? path : ("http://"+serverAddress+":"+serverPort + (path.startsWith("/") ? path : ("/"+path))); 447 | } 448 | 449 | private void handlePlayerStatus(String id, String mode, String remote_title, String artist, String album, String title, 450 | String artwork_url, String coverid, long duration, long time) { 451 | PlayerStatus status = new PlayerStatus(); 452 | status.id = id; 453 | status.timestamp = SystemClock.elapsedRealtime(); 454 | status.artist = artist; 455 | status.album = album; 456 | if (!Utils.isEmpty(remote_title) && (!remote_title.startsWith("http") || Utils.isEmpty(title))) { 457 | status.title = remote_title; 458 | } else { 459 | status.title = title; 460 | } 461 | status.duration = duration; 462 | status.time = "stop".equals(mode) ? 0 : time; 463 | status.isPlaying = "play".equals(mode); 464 | if (!Utils.isEmpty(artwork_url)) { 465 | String resolved = resolveImageUrl(artwork_url); 466 | if (!Utils.isEmpty(resolved)) { 467 | status.cover = coverUrl(resolved); 468 | } 469 | } 470 | if (Utils.isEmpty(status.cover) && !Utils.isEmpty(coverid)) { 471 | status.cover = coverUrl("/music/"+coverid+"/cover"+IMAGE_SIZE); 472 | } 473 | Utils.debug(status.toString()); 474 | service.updatePlayerStatus(status); 475 | } 476 | 477 | private float parseFloat(Object val) { 478 | if (null==val) { 479 | return 0.0f; 480 | } 481 | if (val instanceof Float) { 482 | return (Float)val; 483 | } 484 | if (val instanceof Double) { 485 | return ((Double)val).floatValue(); 486 | } 487 | if (val instanceof String) { 488 | try { 489 | return Float.parseFloat((String) val); 490 | } catch (NumberFormatException ignored) { } 491 | } 492 | return 0.0f; 493 | } 494 | 495 | private String getString(JSONObject json, String key) { 496 | try { 497 | return json.getString(key); 498 | } catch (Exception ignored) { 499 | return ""; 500 | } 501 | } 502 | 503 | private float getFloat(JSONObject json, String key) { 504 | try { 505 | return (float) json.getDouble(key); 506 | } catch (Exception ignored) { 507 | return parseFloat(getString(json, key)); 508 | } 509 | } 510 | 511 | private synchronized void handlePlayerStatus(String id, JSONObject response) { 512 | Utils.verbose("JSON " + id); 513 | if (!Objects.equals(id, currentPlayer)) { 514 | return; 515 | } 516 | JSONArray playlist_loop = null; 517 | try { 518 | playlist_loop = response.getJSONArray("playlist_loop"); 519 | } catch (JSONException ignored) { } 520 | if (playlist_loop!=null && playlist_loop.length()>0) { 521 | JSONObject track = null; 522 | try { 523 | track = playlist_loop.getJSONObject(0); 524 | } catch (JSONException ignored) { } 525 | if (null!=track) { 526 | handlePlayerStatus(id, 527 | getString(response, "mode"), 528 | getString(track, "remote_title"), 529 | getString(track, "artist"), 530 | getString(track, "album"), 531 | getString(track, "title"), 532 | getString(track, "artwork_url"), 533 | getString(track, "coverid"), 534 | (long) (getFloat(track, "duration") * 1000.0f), 535 | (long) (getFloat(response, "time") * 1000.0f)); 536 | return; 537 | } 538 | } 539 | handlePlayerStatus(id, getString(response, "mode"), null, null, null, null, null, null, 0, 0); 540 | } 541 | 542 | @SuppressWarnings("unchecked") 543 | private synchronized void handlePlayerStatus(ClientSessionChannel channel, Message message) { 544 | String[] parts = message.getChannel().split("/"); 545 | String playerId = parts[parts.length - 1]; 546 | Utils.verbose("CometD " + playerId); 547 | 548 | if (!Objects.equals(playerId, currentPlayer)) { 549 | return; 550 | } 551 | 552 | Map messageData = message.getDataAsMap(); 553 | Object[] playlist_loop = (Object[]) messageData.get("playlist_loop"); 554 | 555 | if (playlist_loop!=null && playlist_loop.length>0) { 556 | Map track = (Map)playlist_loop[0]; 557 | handlePlayerStatus(playerId, 558 | (String)messageData.get("mode"), 559 | (String)track.get("remote_title"), 560 | (String)track.get("artist"), 561 | (String)track.get("album"), 562 | (String)track.get("title"), 563 | (String)track.get("artwork_url"), 564 | (String)track.get("coverid"), 565 | (long)(parseFloat(track.get("duration"))*1000.0f), 566 | (long)(parseFloat(messageData.get("time"))*1000.0f)); 567 | } else { 568 | handlePlayerStatus(playerId, (String)messageData.get("mode"), null, null, null, null, null, null, 0, 0); 569 | } 570 | } 571 | } -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/cometd/ConnectionState.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * NOTE: This file copied from https://github.com/kaaholst/android-squeezer 5 | * 6 | * Apache-2.0 license 7 | */ 8 | 9 | package com.craigd.lmsmaterial.app.cometd; 10 | 11 | import android.os.SystemClock; 12 | 13 | import androidx.annotation.NonNull; 14 | 15 | import com.craigd.lmsmaterial.app.Utils; 16 | 17 | 18 | public class ConnectionState { 19 | ConnectionState() { 20 | } 21 | 22 | public enum Error { 23 | START_CLIENT_ERROR, 24 | INVALID_URL, 25 | LOGIN_FALIED, 26 | CONNECTION_ERROR; 27 | } 28 | 29 | public enum State { 30 | /** User disconnected */ 31 | MANUAL_DISCONNECT, 32 | /** Ordinarily disconnected from the server. */ 33 | DISCONNECTED, 34 | /** A connection has been started. */ 35 | CONNECTION_STARTED, 36 | /** The connection to the server did not complete. */ 37 | CONNECTION_FAILED, 38 | /** The connection to the server completed, the handshake can start. */ 39 | CONNECTION_COMPLETED, 40 | /** Currently trying to reestablish a previously working connection. */ 41 | REHANDSHAKING; 42 | 43 | boolean isConnected() { 44 | return (this == CONNECTION_COMPLETED); 45 | } 46 | 47 | boolean isConnectInProgress() { 48 | return (this == CONNECTION_STARTED); 49 | } 50 | 51 | /** 52 | * @return True if the socket connection to the server has started, but not yet 53 | * completed (successfully or unsuccessfully). 54 | */ 55 | boolean isRehandshaking() { 56 | return (this == REHANDSHAKING); 57 | } 58 | } 59 | 60 | private volatile State state = State.DISCONNECTED; 61 | 62 | /** Minimum milliseconds between automatic connection */ 63 | private static final long AUTO_CONNECT_INTERVAL = 60_000; 64 | 65 | /** Milliseconds since boot of latest start of rehandshake */ 66 | private volatile long rehandshake; 67 | 68 | /** Duration before we give up rehandshake */ 69 | private static final long REHANDSHAKE_TIMEOUT = 15 * 60_000; 70 | 71 | /** 72 | * Sets a new connection state, and posts a sticky 73 | * {@link uk.org.ngo.squeezer.service.event.ConnectionChanged} event with the new state. 74 | * 75 | * @param connectionState The new connection state. 76 | */ 77 | void setConnectionState(State connectionState) { 78 | Utils.info(state + " => " + connectionState); 79 | updateConnectionState(connectionState); 80 | //TODO mEventBus.postSticky(new ConnectionChanged(connectionState)); 81 | } 82 | 83 | void setConnectionError(Error connectionError) { 84 | Utils.info(state + " => " + connectionError); 85 | updateConnectionState(State.CONNECTION_FAILED); 86 | //TODO mEventBus.postSticky(new ConnectionChanged(connectionError)); 87 | } 88 | 89 | private void updateConnectionState(State connectionState) { 90 | // Clear data if we were previously connected 91 | if (isConnected() && !connectionState.isConnected()) { 92 | // TODO mEventBus.removeAllStickyEvents(); 93 | } 94 | 95 | // Start timer for rehandshake 96 | if (connectionState == State.REHANDSHAKING) { 97 | rehandshake = SystemClock.elapsedRealtime(); 98 | } 99 | state = connectionState; 100 | } 101 | 102 | boolean isConnected() { 103 | return state.isConnected(); 104 | } 105 | 106 | boolean isConnectInProgress() { 107 | return state.isConnectInProgress(); 108 | } 109 | 110 | boolean isRehandshaking() { 111 | return state.isRehandshaking(); 112 | } 113 | 114 | boolean canRehandshake() { 115 | return isRehandshaking() 116 | && ((SystemClock.elapsedRealtime() - rehandshake) < REHANDSHAKE_TIMEOUT); 117 | } 118 | 119 | @NonNull 120 | @Override 121 | public String toString() { 122 | return ""+state; 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/cometd/PlayerStatus.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * Copyright (c) 2020-2024 Craig Drummond 5 | * MIT license. 6 | */ 7 | 8 | package com.craigd.lmsmaterial.app.cometd; 9 | 10 | import androidx.annotation.NonNull; 11 | 12 | import com.craigd.lmsmaterial.app.Utils; 13 | 14 | import java.util.LinkedList; 15 | import java.util.List; 16 | 17 | public class PlayerStatus { 18 | public long timestamp; 19 | public String id; 20 | public String title; 21 | public String artist; 22 | public String album; 23 | public String cover; 24 | public long duration = 0; 25 | public long time = 0; 26 | public boolean isPlaying = false; 27 | 28 | @NonNull 29 | @Override 30 | public String toString() { 31 | return "id:"+id+", title:"+title+", artist:"+artist+", album:"+album+", cover:"+cover+", duration:"+Utils.timeStr(duration)+", time:"+Utils.timeStr(time)+", isPlaying:"+isPlaying; 32 | } 33 | 34 | public String display() { 35 | List parts = new LinkedList<>(); 36 | if (!Utils.isEmpty(title)) { 37 | parts.add(title); 38 | } 39 | if (!Utils.isEmpty(artist)) { 40 | parts.add(artist); 41 | } 42 | //if (!Utils.isEmpty(album)) { 43 | // parts.add(album); 44 | //} 45 | return String.join(" • ", parts); 46 | } 47 | } -------------------------------------------------------------------------------- /lms-material/src/main/java/com/craigd/lmsmaterial/app/cometd/SlimClient.java: -------------------------------------------------------------------------------- 1 | /** 2 | * LMS-Material-App 3 | * 4 | * NOTE: This file copied from https://github.com/kaaholst/android-squeezer 5 | * 6 | * Apache-2.0 license 7 | */ 8 | 9 | package com.craigd.lmsmaterial.app.cometd; 10 | 11 | import com.craigd.lmsmaterial.app.Utils; 12 | 13 | import org.cometd.bayeux.Channel; 14 | import org.cometd.bayeux.Message; 15 | import org.cometd.client.BayeuxClient; 16 | import org.cometd.client.transport.ClientTransport; 17 | import org.cometd.common.HashMapMessage; 18 | 19 | import java.io.IOException; 20 | import java.util.List; 21 | 22 | public class SlimClient extends BayeuxClient { 23 | private final ConnectionState connectionState; 24 | 25 | SlimClient(ConnectionState connectionState, String url, ClientTransport transport, ClientTransport... transports) { 26 | super(url, transport, transports); 27 | this.connectionState = connectionState; 28 | } 29 | 30 | public void stop() { 31 | this.terminate(); 32 | } 33 | 34 | @Override 35 | public void onSending(List messages) { 36 | super.onSending(messages); 37 | for (Message message : messages) { 38 | Utils.verbose(message.getJSON()); 39 | } 40 | } 41 | 42 | @Override 43 | public void onMessages(List messages) { 44 | super.onMessages(messages); 45 | for (Message message : messages) { 46 | Utils.verbose(message.getJSON()); 47 | } 48 | } 49 | 50 | @Override 51 | public void onFailure(Throwable failure, List messages) { 52 | super.onFailure(failure, messages); 53 | for (Message message : messages) { 54 | Utils.error(message.getJSON(), failure); 55 | } 56 | if (failure instanceof IOException && connectionState.isConnected()) { 57 | rehandshake(); 58 | } 59 | } 60 | 61 | public void rehandshake() { 62 | Utils.info(""); 63 | connectionState.setConnectionState(ConnectionState.State.REHANDSHAKING); 64 | HashMapMessage message = new HashMapMessage(); 65 | message.setId(newMessageId()); 66 | message.setSuccessful(false); 67 | message.setChannel(Channel.META_HANDSHAKE); 68 | message.getAdvice(true).put(Message.RECONNECT_FIELD, Message.RECONNECT_HANDSHAKE_VALUE); 69 | message.setClientId(getId()); 70 | processHandshake(message); 71 | } 72 | } -------------------------------------------------------------------------------- /lms-material/src/main/res/color/switch_thumb_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /lms-material/src/main/res/color/switch_thumb_color_light.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /lms-material/src/main/res/color/switch_track_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /lms-material/src/main/res/color/switch_track_decoration_color.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /lms-material/src/main/res/color/switch_track_decoration_color_light.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/ic_action_quit.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/ic_action_quit_light.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/ic_download.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 10 | 12 | 14 | 16 | 18 | 20 | 22 | 24 | 26 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 75 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 6 | 10 | 14 | 19 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/ic_mono_icon.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/ic_next.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/ic_pause.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/ic_play.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/ic_prev.xml: -------------------------------------------------------------------------------- 1 | 6 | 9 | 10 | -------------------------------------------------------------------------------- /lms-material/src/main/res/drawable/notification_image.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CDrummond/lms-material-app/762cd9e3ffbc701bae886640c9c8e0007dfe7a65/lms-material/src/main/res/drawable/notification_image.png -------------------------------------------------------------------------------- /lms-material/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 15 | 16 | 17 | 25 | 26 | 31 | 37 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /lms-material/src/main/res/layout/auth_prompt.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 15 | 22 | 26 | 33 | 38 | -------------------------------------------------------------------------------- /lms-material/src/main/res/layout/preference_switch.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /lms-material/src/main/res/layout/preference_switch_light.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /lms-material/src/main/res/layout/settings_activity.xml: -------------------------------------------------------------------------------- 1 | 4 | 5 | 9 | -------------------------------------------------------------------------------- /lms-material/src/main/res/layout/url_handler.xml: -------------------------------------------------------------------------------- 1 | 2 | 6 | 15 | 16 | 23 | 27 | 28 | 32 | 33 |