diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fd2f365 --- /dev/null +++ b/.gitignore @@ -0,0 +1,23 @@ +# Android generated +bin +gen +obj +lint.xml + +# IntelliJ IDEA +.idea +*.iml +*.ipr +*.iws +classes +gen-external-apklibs + +# Gradle +.gradle +build +buildout +out + +# Other +.DS_Store +local.properties diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..d159169 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,339 @@ + GNU GENERAL PUBLIC LICENSE + Version 2, June 1991 + + Copyright (C) 1989, 1991 Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The licenses for most software are designed to take away your +freedom to share and change it. By contrast, the GNU General Public +License is intended to guarantee your freedom to share and change free +software--to make sure the software is free for all its users. This +General Public License applies to most of the Free Software +Foundation's software and to any other program whose authors commit to +using it. (Some other Free Software Foundation software is covered by +the GNU Lesser General Public License instead.) You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +this service if you wish), that you receive source code or can get it +if you want it, that you can change the software or use pieces of it +in new free programs; and that you know you can do these things. + + To protect your rights, we need to make restrictions that forbid +anyone to deny you these rights or to ask you to surrender the rights. +These restrictions translate to certain responsibilities for you if you +distribute copies of the software, or if you modify it. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must give the recipients all the rights that +you have. You must make sure that they, too, receive or can get the +source code. And you must show them these terms so they know their +rights. + + We protect your rights with two steps: (1) copyright the software, and +(2) offer you this license which gives you legal permission to copy, +distribute and/or modify the software. + + Also, for each author's protection and ours, we want to make certain +that everyone understands that there is no warranty for this free +software. If the software is modified by someone else and passed on, we +want its recipients to know that what they have is not the original, so +that any problems introduced by others will not reflect on the original +authors' reputations. + + Finally, any free program is threatened constantly by software +patents. We wish to avoid the danger that redistributors of a free +program will individually obtain patent licenses, in effect making the +program proprietary. To prevent this, we have made it clear that any +patent must be licensed for everyone's free use or not licensed at all. + + The precise terms and conditions for copying, distribution and +modification follow. + + GNU GENERAL PUBLIC LICENSE + TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION + + 0. This License applies to any program or other work which contains +a notice placed by the copyright holder saying it may be distributed +under the terms of this General Public License. The "Program", below, +refers to any such program or work, and a "work based on the Program" +means either the Program or any derivative work under copyright law: +that is to say, a work containing the Program or a portion of it, +either verbatim or with modifications and/or translated into another +language. (Hereinafter, translation is included without limitation in +the term "modification".) Each licensee is addressed as "you". + +Activities other than copying, distribution and modification are not +covered by this License; they are outside its scope. The act of +running the Program is not restricted, and the output from the Program +is covered only if its contents constitute a work based on the +Program (independent of having been made by running the Program). +Whether that is true depends on what the Program does. + + 1. You may copy and distribute verbatim copies of the Program's +source code as you receive it, in any medium, provided that you +conspicuously and appropriately publish on each copy an appropriate +copyright notice and disclaimer of warranty; keep intact all the +notices that refer to this License and to the absence of any warranty; +and give any other recipients of the Program a copy of this License +along with the Program. + +You may charge a fee for the physical act of transferring a copy, and +you may at your option offer warranty protection in exchange for a fee. + + 2. You may modify your copy or copies of the Program or any portion +of it, thus forming a work based on the Program, and copy and +distribute such modifications or work under the terms of Section 1 +above, provided that you also meet all of these conditions: + + a) You must cause the modified files to carry prominent notices + stating that you changed the files and the date of any change. + + b) You must cause any work that you distribute or publish, that in + whole or in part contains or is derived from the Program or any + part thereof, to be licensed as a whole at no charge to all third + parties under the terms of this License. + + c) If the modified program normally reads commands interactively + when run, you must cause it, when started running for such + interactive use in the most ordinary way, to print or display an + announcement including an appropriate copyright notice and a + notice that there is no warranty (or else, saying that you provide + a warranty) and that users may redistribute the program under + these conditions, and telling the user how to view a copy of this + License. (Exception: if the Program itself is interactive but + does not normally print such an announcement, your work based on + the Program is not required to print an announcement.) + +These requirements apply to the modified work as a whole. If +identifiable sections of that work are not derived from the Program, +and can be reasonably considered independent and separate works in +themselves, then this License, and its terms, do not apply to those +sections when you distribute them as separate works. But when you +distribute the same sections as part of a whole which is a work based +on the Program, the distribution of the whole must be on the terms of +this License, whose permissions for other licensees extend to the +entire whole, and thus to each and every part regardless of who wrote it. + +Thus, it is not the intent of this section to claim rights or contest +your rights to work written entirely by you; rather, the intent is to +exercise the right to control the distribution of derivative or +collective works based on the Program. + +In addition, mere aggregation of another work not based on the Program +with the Program (or with a work based on the Program) on a volume of +a storage or distribution medium does not bring the other work under +the scope of this License. + + 3. You may copy and distribute the Program (or a work based on it, +under Section 2) in object code or executable form under the terms of +Sections 1 and 2 above provided that you also do one of the following: + + a) Accompany it with the complete corresponding machine-readable + source code, which must be distributed under the terms of Sections + 1 and 2 above on a medium customarily used for software interchange; or, + + b) Accompany it with a written offer, valid for at least three + years, to give any third party, for a charge no more than your + cost of physically performing source distribution, a complete + machine-readable copy of the corresponding source code, to be + distributed under the terms of Sections 1 and 2 above on a medium + customarily used for software interchange; or, + + c) Accompany it with the information you received as to the offer + to distribute corresponding source code. (This alternative is + allowed only for noncommercial distribution and only if you + received the program in object code or executable form with such + an offer, in accord with Subsection b above.) + +The source code for a work means the preferred form of the work for +making modifications to it. For an executable work, complete source +code means all the source code for all modules it contains, plus any +associated interface definition files, plus the scripts used to +control compilation and installation of the executable. However, as a +special exception, the source code distributed need not include +anything that is normally distributed (in either source or binary +form) with the major components (compiler, kernel, and so on) of the +operating system on which the executable runs, unless that component +itself accompanies the executable. + +If distribution of executable or object code is made by offering +access to copy from a designated place, then offering equivalent +access to copy the source code from the same place counts as +distribution of the source code, even though third parties are not +compelled to copy the source along with the object code. + + 4. You may not copy, modify, sublicense, or distribute the Program +except as expressly provided under this License. Any attempt +otherwise to copy, modify, sublicense or distribute the Program is +void, and will automatically terminate your rights under this License. +However, parties who have received copies, or rights, from you under +this License will not have their licenses terminated so long as such +parties remain in full compliance. + + 5. You are not required to accept this License, since you have not +signed it. However, nothing else grants you permission to modify or +distribute the Program or its derivative works. These actions are +prohibited by law if you do not accept this License. Therefore, by +modifying or distributing the Program (or any work based on the +Program), you indicate your acceptance of this License to do so, and +all its terms and conditions for copying, distributing or modifying +the Program or works based on it. + + 6. Each time you redistribute the Program (or any work based on the +Program), the recipient automatically receives a license from the +original licensor to copy, distribute or modify the Program subject to +these terms and conditions. You may not impose any further +restrictions on the recipients' exercise of the rights granted herein. +You are not responsible for enforcing compliance by third parties to +this License. + + 7. If, as a consequence of a court judgment or allegation of patent +infringement or for any other reason (not limited to patent issues), +conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot +distribute so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you +may not distribute the Program at all. For example, if a patent +license would not permit royalty-free redistribution of the Program by +all those who receive copies directly or indirectly through you, then +the only way you could satisfy both it and this License would be to +refrain entirely from distribution of the Program. + +If any portion of this section is held invalid or unenforceable under +any particular circumstance, the balance of the section is intended to +apply and the section as a whole is intended to apply in other +circumstances. + +It is not the purpose of this section to induce you to infringe any +patents or other property right claims or to contest validity of any +such claims; this section has the sole purpose of protecting the +integrity of the free software distribution system, which is +implemented by public license practices. Many people have made +generous contributions to the wide range of software distributed +through that system in reliance on consistent application of that +system; it is up to the author/donor to decide if he or she is willing +to distribute software through any other system and a licensee cannot +impose that choice. + +This section is intended to make thoroughly clear what is believed to +be a consequence of the rest of this License. + + 8. If the distribution and/or use of the Program is restricted in +certain countries either by patents or by copyrighted interfaces, the +original copyright holder who places the Program under this License +may add an explicit geographical distribution limitation excluding +those countries, so that distribution is permitted only in or among +countries not thus excluded. In such case, this License incorporates +the limitation as if written in the body of this License. + + 9. The Free Software Foundation may publish revised and/or new versions +of the General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + +Each version is given a distinguishing version number. If the Program +specifies a version number of this License which applies to it and "any +later version", you have the option of following the terms and conditions +either of that version or of any later version published by the Free +Software Foundation. If the Program does not specify a version number of +this License, you may choose any version ever published by the Free Software +Foundation. + + 10. If you wish to incorporate parts of the Program into other free +programs whose distribution conditions are different, write to the author +to ask for permission. For software which is copyrighted by the Free +Software Foundation, write to the Free Software Foundation; we sometimes +make exceptions for this. Our decision will be guided by the two goals +of preserving the free status of all derivatives of our free software and +of promoting the sharing and reuse of software generally. + + NO WARRANTY + + 11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY +FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN +OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES +PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED +OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS +TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE +PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, +REPAIR OR CORRECTION. + + 12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR +REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, +INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING +OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED +TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY +YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER +PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE +POSSIBILITY OF SUCH DAMAGES. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +convey the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation; either version 2 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License along + with this program; if not, write to the Free Software Foundation, Inc., + 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. + +Also add information on how to contact you by electronic and paper mail. + +If the program is interactive, make it output a short notice like this +when it starts in an interactive mode: + + Gnomovision version 69, Copyright (C) year name of author + Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, the commands you use may +be called something other than `show w' and `show c'; they could even be +mouse-clicks or menu items--whatever suits your program. + +You should also get your employer (if you work as a programmer) or your +school, if any, to sign a "copyright disclaimer" for the program, if +necessary. Here is a sample; alter the names: + + Yoyodyne, Inc., hereby disclaims all copyright interest in the program + `Gnomovision' (which makes passes at compilers) written by James Hacker. + + , 1 April 1989 + Ty Coon, President of Vice + +This General Public License does not permit incorporating your program into +proprietary programs. If your program is a subroutine library, you may +consider it more useful to permit linking proprietary applications with the +library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..0ef8452 --- /dev/null +++ b/README.md @@ -0,0 +1,191 @@ +#### [ExoPlayer AirPlay Receiver](https://github.com/warren-bank/Android-ExoPlayer-AirPlay-Receiver) +##### (less formally named: _"ExoAirPlayer"_) + +Android app to run on a set-top box and play video URLs "cast" to it with a stateless HTTP API (based on AirPlay). + +- - - - + +#### Background: + +* I use Chromecasts _a lot_ + - they are incredibly adaptable + * though their protocol is [proprietary and locked down](https://blog.oakbits.com/google-cast-protocol-receiver-authentication.html) + - though the [Google Cast SDK for Android](https://developers.google.com/cast/docs/android_sender) is nearly ubiquitous, I very rarely cast video from apps + - I find much better video content to stream on websites, and wrote some tools to identify and cast these URLs + * [Chrome extension](https://github.com/warren-bank/crx-webcast-reloaded) to use with desktop web browsers + * [Android app](https://github.com/warren-bank/Android-WebCast) to use with mobile devices +* I also really like using Android set-top boxes + - mainly to play video files stored on an attached drive + - they are incredibly adaptable + * able to run any Android apk, such as: + - VPN client + - torrent client + - FTP client + - HTTP server +* I thought it would be "fun" to write an app to run on Android set-top boxes that could provide the same functionality that I enjoy on Chromecasts + - and will work equally well on smaller screens (ex: phones and tablets) + +#### Scope: + +* the goal is __not__ to provide an app that is recognized on the LAN as a virtual Chromecast device + - [CheapCast](https://github.com/mauimauer/cheapcast) accomplished this in 2013 + * Google quickly [changed its protocol](https://blog.oakbits.com/google-cast-protocol-discovery-and-connection.html) +* AirPlay uses a very simple stateless [HTTP API](http://nto.github.io/AirPlay.html#video) + - this is a great starting point + * it supports: play, pause, seek, stop + - I'd like to extend this API (for a custom sender) + * to add support for: video queue, next, previous, mute, set volume + +#### Design: + +* [ExoPlayer](https://github.com/google/ExoPlayer) + - media player used to render video URLs +* [HttpCore](http://hc.apache.org/httpcomponents-core-ga/) + - low level HTTP transport components used to build a custom HTTP service +* [jmDNS](https://github.com/jmdns/jmdns) + - multi-cast DNS service registration used to make AirPlay-compatible HTTP service discoverable on LAN + +- - - - + +#### Usage (low level): + +__AirPlay APIs:__ + +```bash + # network address for running instance of 'ExoPlayer AirPlay Receiver' + airplay_ip='192.168.1.100:8192' + + # URLs for test videos: + videos_page='https://players.akamai.com/hls/' + video_url_1='https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8' + video_url_2='https://multiplatform-f.akamaihd.net/i/multi/april11/hdworld/hdworld_,512x288_450_b,640x360_700_b,768x432_1000_b,1024x576_1400_m,.mp4.csmil/master.m3u8' + video_url_3='https://multiplatform-f.akamaihd.net/i/multi/april11/cctv/cctv_,512x288_450_b,640x360_700_b,768x432_1000_b,1024x576_1400_m,.mp4.csmil/master.m3u8' +``` + +* play video #1: + ```bash + curl -X POST \ + -H "Content-Type: text/parameters" \ + --data-binary "Content-Location: ${video_url_1}\nStart-Position: 0" \ + "http://${airplay_ip}/play" + ``` +* seek to `30 seconds` within currently playing video: + ```bash + # note: POST body is required, even when it contains no data + curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/scrub?position=30.0" + ``` +* pause the currently playing video: + ```bash + # note: POST body is required, even when it contains no data + curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/rate?value=0.0" + ``` +* resume playback of the currently paused video: + ```bash + # note: POST body is required, even when it contains no data + curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/rate?value=1.0" + ``` +* increase speed of playback to 10x: + ```bash + # note: POST body is required, even when it contains no data + curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/rate?value=10.0" + ``` +* stop playback: + ```bash + # note: POST body is required, even when it contains no data + curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/stop" + ``` + +__extended APIs:__ + +* add video #2 to end of queue (set 'Referer' request header, seek to 50%): + ```bash + # note: position < 1 is a percent of the total video length + curl -X POST \ + -H "Content-Type: text/parameters" \ + --data-binary "Content-Location: ${video_url_2}\nReferer: ${videos_page}\nStart-Position: 0.5" \ + "http://${airplay_ip}/queue" + ``` +* add video #3 to end of queue (set 'Referer' request header, seek to 30 seconds): + ```bash + # note: position >= 1 is a fixed offset (in seconds) + curl -X POST \ + -H "Content-Type: text/parameters" \ + --data-binary "Content-Location: ${video_url_3}\nReferer: ${videos_page}\nStart-Position: 30" \ + "http://${airplay_ip}/queue" + ``` +* skip forward to next video in queue: + ```bash + # note: POST body is required, even when it contains no data + curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/next" + ``` +* skip backward to previous video in queue: + ```bash + # note: POST body is required, even when it contains no data + curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/previous" + ``` +* mute audio: + ```bash + # note: POST body is required, even when it contains no data + curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/volume?value=0.0" + ``` +* set audio volume to 50%: + ```bash + # note: POST body is required, even when it contains no data + curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/volume?value=0.5" + ``` +* set audio volume to 100%: + ```bash + # note: POST body is required, even when it contains no data + curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/volume?value=1.0" + ``` + +#### Usage (high level): + +__to do:__ + +* update [Chrome extension](https://github.com/warren-bank/crx-webcast-reloaded) to support "casting" videos from websites to a running instance of [ExoPlayer AirPlay Receiver](https://github.com/warren-bank/Android-ExoPlayer-AirPlay-Receiver) + +- - - - + +#### Credits: + +* [AirPlay-Receiver-on-Android](https://github.com/gpfduoduo/AirPlay-Receiver-on-Android) + - __brilliant__ + - what I like: + * quality of code is excellent + * implements 99% of what I've described + - [media player](https://github.com/yixia/VitamioBundle) used to render video URLs + - _HttpCore_ web server that implements all _AirPlay_ video APIs + - _jmDNS_ Bonjour registration + - what I dislike: + * all libraries are 5 years old + * doesn't use _ExoPlayer_ + * the repo includes a lot of unused code + - needs a little housekeeping + +- - - - + +#### Legal: + +* copyright: [Warren Bank](https://github.com/warren-bank) +* license: [GPL-2.0](https://www.gnu.org/licenses/old-licenses/gpl-2.0.txt) diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/build.gradle b/android-studio-project/ExoPlayer-AirPlay-Receiver/build.gradle new file mode 100644 index 0000000..3ec513e --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/build.gradle @@ -0,0 +1,58 @@ +apply from: '../constants.gradle' +apply plugin: 'com.android.application' + +android { + compileSdkVersion project.ext.compileSdkVersion + buildToolsVersion project.ext.buildToolsVersion + + compileOptions { + sourceCompatibility project.ext.javaVersion + targetCompatibility project.ext.javaVersion + } + + defaultConfig { + minSdkVersion project.ext.minSdkVersion + targetSdkVersion project.ext.targetSdkVersion + + applicationId "com.github.warren_bank.exoplayer_airplay_receiver" + versionName project.ext.releaseVersion + versionCode project.ext.releaseVersionCode + } + + buildTypes { + release { + shrinkResources true + minifyEnabled true + proguardFiles = [ + "proguard-rules.txt", + getDefaultProguardFile('proguard-android.txt') + ] + } + debug { + debuggable true + jniDebuggable true + } + } + + lintOptions { + disable 'MissingTranslation' + abortOnError true + } +} + +dependencies { + compileOnly 'androidx.annotation:annotation:' + project.ext.libVersionAndroidX // ( 27 KB) https://mvnrepository.com/artifact/androidx.annotation/annotation + annotationProcessor 'androidx.annotation:annotation:' + project.ext.libVersionAndroidX + + implementation 'com.google.android.exoplayer:exoplayer-core:' + project.ext.libVersionExoPlayer // (1.3 MB) https://mvnrepository.com/artifact/com.google.android.exoplayer/exoplayer-core?repo=bt-google-exoplayer + implementation 'com.google.android.exoplayer:exoplayer-dash:' + project.ext.libVersionExoPlayer // (107 KB) https://mvnrepository.com/artifact/com.google.android.exoplayer/exoplayer-dash?repo=bt-google-exoplayer + implementation 'com.google.android.exoplayer:exoplayer-hls:' + project.ext.libVersionExoPlayer // ( 98 KB) https://mvnrepository.com/artifact/com.google.android.exoplayer/exoplayer-hls?repo=bt-google-exoplayer + implementation 'com.google.android.exoplayer:exoplayer-smoothstreaming:' + project.ext.libVersionExoPlayer // ( 44 KB) https://mvnrepository.com/artifact/com.google.android.exoplayer/exoplayer-smoothstreaming?repo=bt-google-exoplayer + implementation 'com.google.android.exoplayer:exoplayer-ui:' + project.ext.libVersionExoPlayer // (238 KB) https://mvnrepository.com/artifact/com.google.android.exoplayer/exoplayer-ui?repo=bt-google-exoplayer + + implementation 'com.googlecode.plist:dd-plist:' + project.ext.libVersionDdPlist // ( 74 KB) https://mvnrepository.com/artifact/com.googlecode.plist/dd-plist + implementation 'org.apache.httpcomponents:httpcore:' + project.ext.libVersionHttpCore // (320 KB) https://mvnrepository.com/artifact/org.apache.httpcomponents/httpcore + implementation 'org.jmdns:jmdns:' + project.ext.libVersionJmDNS // (209 KB) https://mvnrepository.com/artifact/org.jmdns/jmdns +} + +apply plugin: 'com.google.android.gms.strict-version-matcher-plugin' diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/proguard-rules.txt b/android-studio-project/ExoPlayer-AirPlay-Receiver/proguard-rules.txt new file mode 100644 index 0000000..d1674ff --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/proguard-rules.txt @@ -0,0 +1,10 @@ +-keep class com.github.warren_bank.exoplayer_airplay_receiver.** { *; } + +-keep class com.google.android.exoplayer.** { *; } +-keep class com.google.android.exoplayer2.** { *; } + +-keep class javax.jmdns.** { *; } + +-dontwarn javax.jmdns.test.** +-dontwarn org.apache.** +-dontwarn org.slf4j.** diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/AndroidManifest.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/AndroidManifest.xml new file mode 100644 index 0000000..c17a9be --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/AndroidManifest.xml @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/MainApp.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/MainApp.java new file mode 100644 index 0000000..87dc54a --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/MainApp.java @@ -0,0 +1,41 @@ +package com.github.warren_bank.exoplayer_airplay_receiver; + +import java.util.concurrent.ConcurrentHashMap; + +import android.app.Application; +import android.os.Handler; +import android.os.Message; + +public class MainApp extends Application { + private static MainApp instance; + + private ConcurrentHashMap mHandlerMap = new ConcurrentHashMap(); + + public static MainApp getInstance() { + return instance; + } + + public static void registerHandler(String name, Handler handler) { + getInstance().getHandlerMap().put(name, handler); + } + + public static void unregisterHandler(String name) { + getInstance().getHandlerMap().remove(name); + } + + public static void broadcastMessage(Message msg) { + for (Handler handler : getInstance().getHandlerMap().values()) { + handler.sendMessage(Message.obtain(msg)); + } + } + + @Override + public void onCreate() { + super.onCreate(); + instance = this; + } + + public ConcurrentHashMap getHandlerMap() { + return mHandlerMap; + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/constant/Constant.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/constant/Constant.java new file mode 100644 index 0000000..b1b8f85 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/constant/Constant.java @@ -0,0 +1,198 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.constant; + +public class Constant { + public static final int AIRPLAY_PORT = 8192; + public static final int RAOP_PORT = 5000; + + public static final String Need_sendReverse = "SendReverse"; + public static final String ReverseMsg = "ReverseMsg"; + public static final String PlayURL = "playUrl"; + public static final String RefererURL = "referUrl"; + public static final String Start_Pos = "startPos"; + + public static String getServerInfoResponse(String mac) { + return "\r\n" + + "\r\n" + + "\r\n" + "\r\n" + "deviceid\r\n" + + "" + mac + "\r\n" + "features\r\n" + + "10623\r\n" + "model\r\n" + + "AppleTV2,1\r\n" + "protovers\r\n" + + "1.0\r\n" + "srcvers\r\n" + + "130.14\r\n" + "\r\n" + ""; + } + + /** + * event sent by the server to the client + * + * @param type 0 (image), 1 (video) + * @param sessionId + * @param state + * @return + */ + public static String getStopEventMsg(int type, String sessionId, String state) { + String category = ""; + String bodyStr = ""; + if (type == 0) { + category = "photo"; + bodyStr = "\n" + + "\n" + + "\n" + "\n" + "category\n" + + "" + category + "\n" + "sessionID\n" + + "1\n" + "reason\n" + + "ended\n" + "state\n" + "" + state + + "\n" + "\n" + "\n"; + } + else if (type == 1) { + category = "video"; + bodyStr = "\n" + + "\n" + + "\n" + "\n" + "category\n" + + "" + category + "\n" + "reason\n" + + "ended\n" + "state\n" + "" + state + + "\n" + "\n" + "\n"; + } + + String sendMsg = "POST /event HTTP/1.1\r\n" + "X-Apple-Session-ID:" + sessionId + + "\r\n" + "Content-Type: text/x-apple-plist+xml\r\n" + "Content-Length:" + + bodyStr.length() + "\r\n\r\n" + bodyStr; + + return sendMsg; + } + + public static String getImageStopEvent() { + String bodyStr = "\n" + + "\n" + + "\n" + "\n" + "category\n" + + "photo\n" + "reason\n" + + "ended\n" + "state\n" + + "stopped\n" + "\n" + "\n"; + + return bodyStr; + } + + public static String getVideoStopEvent() { + String bodyStr = "\n" + + "\n" + + "\n" + "\n" + "category\n" + + "video\n" + "reason\n" + + "ended\n" + "state\n" + + "stopped\n" + "\n" + "\n"; + + return bodyStr; + } + + public static String getVideoEvent(String state) { + return "\n" + + "\n" + + "\n" + "\n" + "category\n" + + "video\n" + "state\n" + "" + state + + "\n" + "\n" + "\n"; + } + + public static String getVideoEventMsg(String sessionId, String state) { + String category = "video"; + String bodyStr = "\n" + + "\n" + + "\n" + "\n" + "category\n" + + "" + category + "\n" + "state\n"; + + String sendMsg = "POST /event HTTP/1.1\r\n" + "X-Apple-Session-ID:" + sessionId + + "\r\n" + "Content-Type: text/x-apple-plist+xml\r\n" + "Content-Length:" + + bodyStr.length() + "\r\n\r\n" + bodyStr; + + return sendMsg; + } + + public static String getPlaybackInfo(float duration, float cacheDuration, + float curPos, int playing) { + String info = " \n" + + "\n" + + "\n" + "\n" + "duration\n" + + "" + + duration + + "\n" + + "loadedTimeRanges\n" + + "\n" + + " \n" + + " duration\n" + + " " + + cacheDuration + + "\n" + + " start\n" + + " 0.0\n" + + " \n" + + "\n" + + "playbackBufferEmpty\n" + + "\n" + + "playbackBufferFull\n" + + "\n" + + "playbackLikelyToKeepUp\n" + + "\n" + + "position\n" + + "" + + curPos + + "\n" + + "rate\n" + + "" + + playing + + "\n" + + "readyToPlay" + + "\n" + + "seekableTimeRanges\n" + + "\n" + + " \n" + + " duration\n" + + " " + + duration + + "\n" + + " start\n" + + " 0.0\n" + + " \n" + "\n" + "\n" + "\n"; + + return info; + + } + + public interface Register { + public static final int FAIL = -1; + public static final int OK = 0; + } + + public interface Msg { + public static final int Msg_Photo = 1; + public static final int Msg_Stop = 2; + public static final int Msg_Video_Play = 3; + public static final int Msg_Video_Seek = 4; + public static final int Msg_Video_Rate = 5; + + public static final int Msg_Video_Queue = 6; + public static final int Msg_Video_Next = 7; + public static final int Msg_Video_Prev = 8; + public static final int Msg_Audio_Volume = 9; + } + + public interface Target { + public static final String REVERSE = "/reverse"; + public static final String PHOTO = "/photo"; + public static final String SERVER_INFO = "/server-info"; + public static final String STOP = "/stop"; + public static final String PLAY = "/play"; + public static final String SCRUB = "/scrub"; + public static final String RATE = "/rate"; + public static final String PLAYBACK_INFO = "/playback-info"; + + public static final String QUEUE = "/queue"; + public static final String NEXT = "/next"; + public static final String PREVIOUS = "/previous"; + public static final String VOLUME = "/volume"; + } + + public interface Status { + public static final String Status_play = "playing"; + public static final String Status_stop = "stopped"; + public static final String Status_pause = "paused"; + public static final String Status_load = "loading"; + } + +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyHttpRequestFactory.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyHttpRequestFactory.java new file mode 100644 index 0000000..e4ed9d4 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyHttpRequestFactory.java @@ -0,0 +1,71 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.httpcore; + +import org.apache.http.HttpRequest; +import org.apache.http.MethodNotSupportedException; +import org.apache.http.RequestLine; +import org.apache.http.impl.DefaultHttpRequestFactory; +import org.apache.http.message.BasicHttpEntityEnclosingRequest; +import org.apache.http.message.BasicHttpRequest; + +public class MyHttpRequestFactory extends DefaultHttpRequestFactory { + private static final String[] RFC2616_COMMON_METHODS = {"GET"}; + private static final String[] RFC2616_ENTITY_ENC_METHODS = {"POST", "PUT"}; + private static final String[] RFC2616_SPECIAL_METHODS = {"HEAD", "OPTIONS", "DELETE", "TRACE", "CONNECT"}; + + public MyHttpRequestFactory() { + super(); + } + + private static boolean isOneOf(final String[] methods, final String method) { + for (int i = 0; i < methods.length; i++) { + if (methods[i].equalsIgnoreCase(method)) { + return true; + } + } + return false; + } + + public HttpRequest newHttpRequest(final RequestLine requestline) + throws MethodNotSupportedException { + if (requestline == null) { + throw new IllegalArgumentException("Request line may not be null"); + } + String method = requestline.getMethod(); + System.out.println("in MyHTTPRequestFactory requestMethod=" + method); + if (isOneOf(RFC2616_COMMON_METHODS, method)) { + //System.out.println("RFC2616_COMMON_METHODS,in MyHTTPRequestFactory create new BasicHttpRequest(requestline)"); + return new BasicHttpRequest(requestline); + } + else if (isOneOf(RFC2616_ENTITY_ENC_METHODS, method)) { + //System.out.println("RFC2616_ENTITY_ENC_METHODS,in MyHTTPRequestFactory create new BasicHttpEntityEnclosingRequest(requestline)"); + return new BasicHttpEntityEnclosingRequest(requestline); + } + else if (isOneOf(RFC2616_SPECIAL_METHODS, method)) { + //System.out.println("RFC2616_SPECIAL_METHODS,in MyHTTPRequestFactory create new BasicHttpRequest(requestline)"); + return new BasicHttpRequest(requestline); + } + else if ("200".equalsIgnoreCase(method)) { + //System.out.println("200 Revese HTTP, MyHTTPRequestFactory create new BasicHttpEntityEnclosingRequest(requestline)"); + return new BasicHttpRequest(requestline); + } + else { + throw new MethodNotSupportedException(method + " method not supported"); + } + } + + public HttpRequest newHttpRequest(final String method, final String uri) + throws MethodNotSupportedException { + if (isOneOf(RFC2616_COMMON_METHODS, method)) { + return new BasicHttpRequest(method, uri); + } + else if (isOneOf(RFC2616_ENTITY_ENC_METHODS, method)) { + return new BasicHttpEntityEnclosingRequest(method, uri); + } + else if (isOneOf(RFC2616_SPECIAL_METHODS, method)) { + return new BasicHttpRequest(method, uri); + } + else { + throw new MethodNotSupportedException(method + " method not supported"); + } + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyHttpServerConnection.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyHttpServerConnection.java new file mode 100644 index 0000000..a53daa3 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyHttpServerConnection.java @@ -0,0 +1,32 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.httpcore; + +import java.net.Socket; + +import org.apache.http.HttpRequestFactory; +import org.apache.http.impl.DefaultHttpServerConnection; +import org.apache.http.impl.io.HttpRequestParser; +import org.apache.http.io.HttpMessageParser; +import org.apache.http.io.SessionInputBuffer; +import org.apache.http.params.HttpParams; + +import android.util.Log; + +public class MyHttpServerConnection extends DefaultHttpServerConnection { + + private static final String tag = MyHttpServerConnection.class.getSimpleName(); + + public MyHttpServerConnection() { + super(); + } + + public Socket getCurrentSocket() { + return super.getSocket(); + } + + protected HttpMessageParser createRequestParser(final SessionInputBuffer buffer, final HttpRequestFactory requestFactory, final HttpParams params) { + Log.d(tag, "airplay in MyHttpServerConnection "); + + //Need to add a custom requestFactory, deal with HTTP1.1 200 OK of Reverse request + return new HttpRequestParser(buffer, new MyLineParser(), new MyHttpRequestFactory(), params); + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyHttpService.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyHttpService.java new file mode 100644 index 0000000..9d06ec8 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyHttpService.java @@ -0,0 +1,209 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.httpcore; + +import java.io.IOException; + +import org.apache.http.ConnectionReuseStrategy; +import org.apache.http.HttpEntity; +import org.apache.http.HttpEntityEnclosingRequest; +import org.apache.http.HttpException; +import org.apache.http.HttpRequest; +import org.apache.http.HttpResponse; +import org.apache.http.HttpResponseFactory; +import org.apache.http.HttpServerConnection; +import org.apache.http.HttpStatus; +import org.apache.http.HttpVersion; +import org.apache.http.MethodNotSupportedException; +import org.apache.http.ProtocolException; +import org.apache.http.ProtocolVersion; +import org.apache.http.UnsupportedHttpVersionException; +import org.apache.http.entity.ByteArrayEntity; +import org.apache.http.params.DefaultedHttpParams; +import org.apache.http.params.HttpParams; +import org.apache.http.protocol.ExecutionContext; +import org.apache.http.protocol.HttpContext; +import org.apache.http.protocol.HttpExpectationVerifier; +import org.apache.http.protocol.HttpProcessor; +import org.apache.http.protocol.HttpRequestHandler; +import org.apache.http.protocol.HttpRequestHandlerResolver; +import org.apache.http.util.EncodingUtils; + +import android.util.Log; + +public class MyHttpService { + private static final String tag = MyHttpService.class.getSimpleName(); + + private HttpParams params = null; + private HttpProcessor processor = null; + private HttpRequestHandlerResolver handlerResolver = null; + private ConnectionReuseStrategy connStrategy = null; + private HttpResponseFactory responseFactory = null; + private HttpExpectationVerifier expectationVerifier = null; + + public MyHttpService(final HttpProcessor proc, final ConnectionReuseStrategy connStrategy, final HttpResponseFactory responseFactory) { + super(); + setHttpProcessor(proc); + setConnReuseStrategy(connStrategy); + setResponseFactory(responseFactory); + } + + public void setHttpProcessor(final HttpProcessor processor) { + if (processor == null) { + throw new IllegalArgumentException("HTTP processor may not be null."); + } + this.processor = processor; + } + + public void setConnReuseStrategy(final ConnectionReuseStrategy connStrategy) { + if (connStrategy == null) { + throw new IllegalArgumentException("Connection reuse strategy may not be null"); + } + this.connStrategy = connStrategy; + } + + public void setResponseFactory(final HttpResponseFactory responseFactory) { + if (responseFactory == null) { + throw new IllegalArgumentException("Response factory may not be null"); + } + this.responseFactory = responseFactory; + } + + public void setHandlerResolver(final HttpRequestHandlerResolver handlerResolver) { + this.handlerResolver = handlerResolver; + } + + public void setExpectationVerifier(final HttpExpectationVerifier expectationVerifier) { + this.expectationVerifier = expectationVerifier; + } + + public HttpParams getParams() { + return this.params; + } + + public void setParams(final HttpParams params) { + this.params = params; + } + + public void handleRequest(final HttpServerConnection conn, final HttpContext context) throws IOException, HttpException { + context.setAttribute(ExecutionContext.HTTP_CONNECTION, conn); + HttpResponse response = null; + + try { + HttpRequest request = conn.receiveRequestHeader(); + request.setParams(new DefaultedHttpParams(request.getParams(), this.params)); + + String method = request.getRequestLine().getMethod(); + Log.d(tag, "airplay in HTTpService, method = " + method); + if ("200".equals(method)) { + Log.d(tag, "airplay in HTTPService, Receive iOS HTTP reverse response 200 OK, do nothing just return"); + return; + } + if (context.getAttribute("NO-RESP") != null) { + Log.d(tag, "airplay in HTTPService, get /playback-info response this time!!"); + context.removeAttribute("NO-RESP"); + return; + } + + ProtocolVersion ver = request.getRequestLine().getProtocolVersion(); + if (!ver.lessEquals(HttpVersion.HTTP_1_1)) { + ver = HttpVersion.HTTP_1_1; + } + + if (request instanceof HttpEntityEnclosingRequest) { + if (((HttpEntityEnclosingRequest) request).expectContinue()) { + response = this.responseFactory.newHttpResponse(ver, HttpStatus.SC_CONTINUE, context); + response.setParams(new DefaultedHttpParams(response.getParams(), this.params)); + + if (this.expectationVerifier != null) { + try { + this.expectationVerifier.verify(request, response, context); + } + catch (HttpException ex) { + response = this.responseFactory.newHttpResponse(HttpVersion.HTTP_1_0, HttpStatus.SC_INTERNAL_SERVER_ERROR, context); + response.setParams(new DefaultedHttpParams(response.getParams(), this.params)); + handleException(ex, response); + } + } + if (response.getStatusLine().getStatusCode() < 200) { + conn.sendResponseHeader(response); + conn.flush(); + response = null; + conn.receiveRequestEntity((HttpEntityEnclosingRequest) request); + } + } + else { + conn.receiveRequestEntity((HttpEntityEnclosingRequest) request); + } + } + + if (response == null) { + response = this.responseFactory.newHttpResponse(ver, HttpStatus.SC_OK, context); + response.setParams(new DefaultedHttpParams(response.getParams(), this.params)); + + context.setAttribute(ExecutionContext.HTTP_REQUEST, request); + context.setAttribute(ExecutionContext.HTTP_RESPONSE, response); + + this.processor.process(request, context); + doService(request, response, context); + } + + // Make sure the request content is fully consumed + if (request instanceof HttpEntityEnclosingRequest) { + HttpEntity entity = ((HttpEntityEnclosingRequest) request).getEntity(); + if (entity != null) { + entity.consumeContent(); + } + } + + } + catch (HttpException ex) { + response = this.responseFactory.newHttpResponse(HttpVersion.HTTP_1_0, HttpStatus.SC_INTERNAL_SERVER_ERROR, context); + response.setParams(new DefaultedHttpParams(response.getParams(), this.params)); + handleException(ex, response); + } + + this.processor.process(response, context); + conn.sendResponseHeader(response); + conn.sendResponseEntity(response); + conn.flush(); + + if (!this.connStrategy.keepAlive(response, context)) { + conn.close(); + } + } + + protected void handleException(final HttpException ex, final HttpResponse response) { + if (ex instanceof MethodNotSupportedException) { + response.setStatusCode(HttpStatus.SC_NOT_IMPLEMENTED); + } + else if (ex instanceof UnsupportedHttpVersionException) { + response.setStatusCode(HttpStatus.SC_HTTP_VERSION_NOT_SUPPORTED); + } + else if (ex instanceof ProtocolException) { + response.setStatusCode(HttpStatus.SC_BAD_REQUEST); + } + else { + response.setStatusCode(HttpStatus.SC_INTERNAL_SERVER_ERROR); + } + byte[] msg = EncodingUtils.getAsciiBytes(ex.getMessage()); + ByteArrayEntity entity = new ByteArrayEntity(msg); + entity.setContentType("text/plain; charset=US-ASCII"); + response.setEntity(entity); + } + + protected void doService(final HttpRequest request, final HttpResponse response, final HttpContext context) throws HttpException, IOException { + Log.d(tag, "airplay in HttpService doService"); + HttpRequestHandler handler = null; + if (this.handlerResolver != null) { + String requestURI = request.getRequestLine().getUri(); + Log.d(tag, "airplay in http service, requestUri=" + requestURI); + + handler = this.handlerResolver.lookup(requestURI); + } + if (handler != null) { + handler.handle(request, response, context); + } + else { + response.setStatusCode(HttpStatus.SC_NOT_IMPLEMENTED); + } + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyLineParser.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyLineParser.java new file mode 100644 index 0000000..09de3a9 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/MyLineParser.java @@ -0,0 +1,57 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.httpcore; + +import org.apache.http.ParseException; +import org.apache.http.ProtocolVersion; +import org.apache.http.RequestLine; +import org.apache.http.message.BasicLineParser; +import org.apache.http.message.ParserCursor; +import org.apache.http.util.CharArrayBuffer; + +import android.util.Log; + +public class MyLineParser extends BasicLineParser { + private static final String tag = MyLineParser.class.getSimpleName(); + + public MyLineParser() { + } + + public ProtocolVersion parseProtocolVersion(final CharArrayBuffer buffer, final ParserCursor cursor) throws ParseException { + Log.d(tag, "airplay in MyLineParse, protocol version parse"); + Log.d(tag, "airplay in MyLineParse buffer = " + buffer.toString()); + + if (buffer == null) { + throw new IllegalArgumentException("Char array buffer may not be null"); + } + if (cursor == null) { + throw new IllegalArgumentException("Parser cursor may not be null"); + } + + //Added Reverse HTTP, special handling for 200 OK sent from iOS + if (buffer.toString().startsWith("HTTP/1.1 200 OK")) { + return createProtocolVersion(1, 0); + } + else { + return super.parseProtocolVersion(buffer, cursor); + } + } // parseProtocolVersion + + /** + * Parses a request line. + * + * @param buffer a buffer holding the line to parse + * + * @return the parsed request line + * + * @throws ParseException in case of a parse error + */ + public RequestLine parseRequestLine(final CharArrayBuffer buffer, final ParserCursor cursor) throws ParseException { + Log.d(tag, "airplay in MyLineParse, parseRequestLine(x,x) buffer=" + buffer.toString()); + + if (buffer.toString().startsWith("HTTP/1.1 200 OK")) { + return super.createRequestLine("200", "200", createProtocolVersion(1, 0)); + } + else { + return super.parseRequestLine(buffer, cursor); + } + } // parseRequestLine +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/RequestListenerThread.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/RequestListenerThread.java new file mode 100644 index 0000000..4ec2824 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/httpcore/RequestListenerThread.java @@ -0,0 +1,607 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.httpcore; + +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.text.DecimalFormat; +import java.util.Collections; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +import org.apache.http.Header; +import org.apache.http.HttpEntity; +import org.apache.http.HttpEntityEnclosingRequest; +import org.apache.http.HttpException; +import org.apache.http.HttpRequest; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.NoConnectionReuseStrategy; //org.apache.http.impl.DefaultConnectionReuseStrategy +import org.apache.http.impl.DefaultHttpResponseFactory; +import org.apache.http.params.BasicHttpParams; +import org.apache.http.params.CoreConnectionPNames; +import org.apache.http.params.CoreProtocolPNames; +import org.apache.http.params.HttpParams; +import org.apache.http.protocol.BasicHttpContext; +import org.apache.http.protocol.BasicHttpProcessor; +import org.apache.http.protocol.ExecutionContext; +import org.apache.http.protocol.HttpContext; +import org.apache.http.protocol.HttpRequestHandler; +import org.apache.http.protocol.HttpRequestHandlerRegistry; +import org.apache.http.protocol.ResponseConnControl; +import org.apache.http.protocol.ResponseContent; +import org.apache.http.protocol.ResponseDate; +import org.apache.http.protocol.ResponseServer; +import org.apache.http.util.EntityUtils; + +import android.os.Message; +import android.text.TextUtils; +import android.util.Log; + +import com.github.warren_bank.exoplayer_airplay_receiver.MainApp; +import com.github.warren_bank.exoplayer_airplay_receiver.constant.Constant; +import com.github.warren_bank.exoplayer_airplay_receiver.ui.VideoPlayerActivity; +import com.github.warren_bank.exoplayer_airplay_receiver.utils.BplistParser; +import com.github.warren_bank.exoplayer_airplay_receiver.utils.NetworkUtils; +import com.github.warren_bank.exoplayer_airplay_receiver.utils.StringUtils; + +public class RequestListenerThread extends Thread { + private static final String tag = RequestListenerThread.class.getSimpleName(); + + public static Map photoCacheMaps = Collections.synchronizedMap(new HashMap()); + private static Map socketMaps = Collections.synchronizedMap(new HashMap()); + private static String localMac = null; + + private ServerSocket serversocket; + private HttpParams params; + private InetAddress localAddress; + private MyHttpService httpService; + + public RequestListenerThread() { + } + + public void run() { + try { + Thread.sleep(2 * 1000); + initHttpServer(); + } + catch (IOException e) { + e.printStackTrace(); + } + catch (InterruptedException e) { + e.printStackTrace(); + } + ExecutorService exec = Executors.newCachedThreadPool(); + + while (!Thread.interrupted()) { + try { + if (serversocket == null) + break; + + Socket socket = this.serversocket.accept(); + Log.d(tag, "airplay incoming connection from " + socket.getInetAddress() + "; socket id= [" + socket + "]"); + + MyHttpServerConnection conn = new MyHttpServerConnection(); + conn.bind(socket, this.params); + + Thread thread = new WorkerThread(this.httpService, conn, socket); + thread.setDaemon(true); + exec.execute(thread); + } + catch (IOException e) { + e.printStackTrace(); + break; + } + } + exec.shutdown(); + Log.d(tag, "exec shut down"); + } + + @Override + public void destroy() { + try { + Log.d(tag, "serverSocket destroy"); + this.interrupt(); + this.serversocket.close(); + this.serversocket = null; + } + catch (Exception e) { + e.printStackTrace(); + } + } + + private void initHttpServer() throws IOException { + Log.d(tag, "airplay init http server"); + + localAddress = NetworkUtils.getLocalIpAddress(); + + if (localAddress == null) { + Thread.interrupted(); + return; + } + + String[] str_Array = new String[2]; + try { + str_Array = NetworkUtils.getMACAddress(localAddress); + String strMac = str_Array[0]; + localMac = strMac.toUpperCase(Locale.ENGLISH); + Log.d(tag, "airplay local mac = " + localMac); + } + catch (Exception e) { + e.printStackTrace(); + } + + serversocket = new ServerSocket(Constant.AIRPLAY_PORT, 2, localAddress); + serversocket.setReuseAddress(true); + + params = new BasicHttpParams(); + params + .setIntParameter(CoreConnectionPNames.SOCKET_BUFFER_SIZE, 8 * 1024) + .setBooleanParameter(CoreConnectionPNames.STALE_CONNECTION_CHECK, false) + .setBooleanParameter(CoreConnectionPNames.TCP_NODELAY, true) + .setParameter(CoreProtocolPNames.ORIGIN_SERVER, "HttpComponents/1.1"); + + BasicHttpProcessor httpProcessor = new BasicHttpProcessor(); //http protocol handler + httpProcessor.addInterceptor(new ResponseDate()); //http protocol interceptor, response date + httpProcessor.addInterceptor(new ResponseServer()); //Response server + httpProcessor.addInterceptor(new ResponseContent()); //Response content + httpProcessor.addInterceptor(new ResponseConnControl()); //Responsive connection control + + //http request handler parser + HttpRequestHandlerRegistry registry = new HttpRequestHandlerRegistry(); + + //http request handler, HttpFileHandler inherits from HttpRequestHandler + registry.register("*", new WebServiceHandler()); + + httpService = new MyHttpService( + httpProcessor, + new NoConnectionReuseStrategy(), //DefaultConnectionReuseStrategy() + new DefaultHttpResponseFactory() + ); + httpService.setParams(this.params); + httpService.setHandlerResolver(registry); //Set up a registered request handler for the http service. + } + + private static class WorkerThread extends Thread { + private static final String tag = WorkerThread.class.getSimpleName(); + + private final MyHttpService httpService; + private final MyHttpServerConnection conn; + private final Socket socket; + + public WorkerThread(final MyHttpService httpService, final MyHttpServerConnection conn, final Socket socket) { + super(); + this.httpService = httpService; + this.conn = conn; + this.socket = socket; + } + + public void run() { + Log.d(tag, "airplay create new connection thread id = " + this.getId() + " handler http client request, socket id = " + "[" + socket + "]"); + + HttpContext context = new BasicHttpContext(null); + + try { + while (!Thread.interrupted() && this.conn.isOpen()) { + this.httpService.handleRequest(this.conn, context); + Log.d(tag, "socket maps size = " + socketMaps.size()); + + String needSendReverse = (String) context.getAttribute(Constant.Need_sendReverse); + if (socketMaps.size() != 1) + continue; + + String sessionId = (String) socketMaps.entrySet().iterator().next().getKey(); + Log.d(tag, "airplay need send reverse = " + needSendReverse + "; sessionId = " + sessionId); + + if (needSendReverse != null && sessionId != null) { + if (socketMaps.containsKey(sessionId)) { + Socket socket = (Socket) socketMaps.get(sessionId); + String httpMsg = (String) context.getAttribute(Constant.ReverseMsg); + Log.d(tag, "airplay sendReverseMsg: " + httpMsg + " on socket " + "[" + socket + "]" + "; sessionId = " + sessionId); + + sendReverseMsg(socket, httpMsg); + + context.removeAttribute(Constant.Need_sendReverse); + context.removeAttribute(Constant.ReverseMsg); + + if (Constant.Status.Status_stop.equals(needSendReverse)) { + if (socket != null && !socket.isClosed()) { + Log.d(tag, "airplay close socket"); + socket.close(); + socketMaps.clear(); + } + this.conn.shutdown(); + this.interrupt(); + } + } + } + } + } + catch (IOException e) { + e.printStackTrace(); + } + catch (HttpException e) { + e.printStackTrace(); + } + finally { + try { + Log.d(tag, "connection shutdown"); + this.conn.shutdown(); + } + catch (IOException e) { + e.printStackTrace(); + } + } + } + + private void sendReverseMsg(Socket socket, String httpMsg) { + if (socket == null || TextUtils.isEmpty(httpMsg)) + return; + if (socket.isConnected()) { + OutputStreamWriter osw; + try { + osw = new OutputStreamWriter(socket.getOutputStream()); + osw.write(httpMsg); + osw.flush(); + } + catch (IOException e) { + e.printStackTrace(); + } + } + } + } + + /** + * all the protocol interactions between apple client (iphone ipad) and + * Android device are processed here + */ + private static class WebServiceHandler implements HttpRequestHandler { + private static final String tag = WebServiceHandler.class.getSimpleName(); + + public WebServiceHandler() { + super(); + } + + //in this method, we can process the requested business logic + @Override + public void handle(HttpRequest httpRequest, HttpResponse httpResponse, HttpContext httpContext) throws HttpException, IOException { + Log.d(tag, "airplay in WebServiceHandler"); + + String method = httpRequest.getRequestLine().getMethod().toUpperCase(Locale.ENGLISH); + + MyHttpServerConnection currentConn = (MyHttpServerConnection) httpContext.getAttribute(ExecutionContext.HTTP_CONNECTION); + + String target = httpRequest.getRequestLine().getUri(); + Header typeHead = httpRequest.getFirstHeader("content-type"); + String contentType = ""; + if (null != typeHead) + contentType = typeHead.getValue(); + Log.d(tag, "airplay incoming HTTP method = " + method + "; target = " + target + "; contentType = " + contentType); + + Header sessionHead = httpRequest.getFirstHeader("X-Apple-Session-ID"); + String sessionId = ""; + + //IOS 8.4.1 When playing a video, only the sessionId is used when the target is play. Every command in the picture has a sessionId. + + if (sessionHead != null) { + sessionId = sessionHead.getValue(); + Log.d(tag, "incoming HTTP airplay session id = " + sessionId); + + if (target.equals(Constant.Target.REVERSE) || target.equals(Constant.Target.PLAY)) { + socketMaps.clear(); + Socket reverseSocket = currentConn.getCurrentSocket(); + if (!socketMaps.containsKey(sessionId)) { + socketMaps.put(sessionId, reverseSocket); + Log.d(tag, "airplay add sock to maps"); + } + } + } + + String requestBody = ""; + byte[] entityContent = null; + if (httpRequest instanceof HttpEntityEnclosingRequest) { + HttpEntity entity = ((HttpEntityEnclosingRequest) httpRequest).getEntity(); + entityContent = EntityUtils.toByteArray(entity); + } + + if (target.equals(Constant.Target.REVERSE)) { + httpResponse.setStatusCode(HttpStatus.SC_SWITCHING_PROTOCOLS); + + /* + * HTTP/1.1 101 Switching Protocols Date: Fri Jul 06 07:17:13 + * 2012 Upgrade: PTTH/1.0 Connection: Upgrade + */ + httpResponse.addHeader("Upgrade", "PTTH/1.0"); + httpResponse.addHeader("Connection", "Upgrade"); + + // Add a HashMap to keep this Socket, ---- + currentConn.setSocketTimeout(0); + // Get the current socket + Socket reverseSocket = currentConn.getCurrentSocket(); + + if (null != sessionId) { + if (!socketMaps.containsKey(sessionId)) { + socketMaps.put(sessionId, reverseSocket); + Log.d(tag, "airplay receive Reverse, keep Socket in HashMap, key=" + sessionId + "; value=" + reverseSocket + ";total Map=" + socketMaps); + } + } + } + else if (target.equals(Constant.Target.SERVER_INFO)) { + String responseStr = Constant.getServerInfoResponse(localMac); + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.addHeader("Date", new Date().toString()); + httpResponse.setEntity(new StringEntity(responseStr)); + } + else if (target.equals(Constant.Target.STOP)) { //Stop message + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.addHeader("Date", new Date().toString()); + + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Stop; + MainApp.broadcastMessage(msg); + + httpContext.setAttribute(Constant.Need_sendReverse, Constant.Status.Status_stop); + httpContext.setAttribute(Constant.ReverseMsg, Constant.getStopEventMsg(0, sessionId, Constant.Status.Status_stop)); + + photoCacheMaps.clear(); + } + else if (target.equals(Constant.Target.PHOTO)) { //Pushed image + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.setHeader("Date", new Date().toString()); + + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Photo; + if (!httpRequest.containsHeader("X-Apple-AssetAction")) { + Log.d(tag, "airplay display image" + "; assetKey = " + httpRequest.getFirstHeader("X-Apple-AssetKey")); + msg.obj = entityContent; + MainApp.broadcastMessage(msg); + } + else { + String assetAction = httpRequest.getFirstHeader("X-Apple-AssetAction").getValue(); + String assetKey = httpRequest.getFirstHeader("X-Apple-AssetKey").getValue(); + if ("cacheOnly".equals(assetAction)) { + Log.d(tag, "airplay cached image, assetKey = " + assetKey); + + if (assetKey != null & entityContent != null) { + if (!photoCacheMaps.containsKey(assetKey)) { + photoCacheMaps.put(assetKey, entityContent); + } + } + } + else if ("displayCached".equals(assetAction)) { + Log.d(tag, "airplay display cached image, assetKey = " + assetKey); + if (photoCacheMaps.containsKey(assetKey)) { + byte[] pic = photoCacheMaps.get(assetKey); + if (pic != null) { + msg.obj = pic; + MainApp.broadcastMessage(msg); + } + } + else { + httpResponse.setStatusCode(HttpStatus.SC_PRECONDITION_FAILED); + } + + } + } + } + else if (target.equals(Constant.Target.PLAY)) { //Pushed videos + String playUrl = ""; + Double startPos = 0.0; + + requestBody = new String(entityContent); + Log.d(tag, " airplay play action request content = " + requestBody); + //a video from iPhone + if (contentType.equalsIgnoreCase("application/x-apple-binary-plist")) { + // + HashMap map = BplistParser.parse(entityContent); + playUrl = (String) map.get("Content-Location"); + startPos = (Double) map.get("Start-Position"); + } + else { + //iTunes pushed videos or Youku + playUrl = StringUtils.getRequestBodyValue(requestBody, "Content-Location:"); + String v = StringUtils.getRequestBodyValue(requestBody, "Start-Position:"); + startPos = (v.isEmpty()) ? 0.0 : Double.valueOf(v); + } + Log.d(tag, "airplay playUrl = " + playUrl + "; start Pos = " + startPos); + + Message msg = Message.obtain(); + HashMap map = new HashMap(); + map.put(Constant.PlayURL, playUrl); + map.put(Constant.Start_Pos, Double.toString(startPos)); + msg.what = Constant.Msg.Msg_Video_Play; + msg.obj = map; + MainApp.broadcastMessage(msg); + + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.setHeader("Date", new Date().toString()); + } + else if (target.startsWith(Constant.Target.SCRUB)) { //POST is the seek operation. GET returns the position and duration of the play. + StringEntity returnBody = new StringEntity(""); + + String position = StringUtils.getQueryStringValue(target, "?position="); + if (!position.isEmpty()) { + //post method + float pos = new Float(position); + Log.d(tag, "airplay seek position = " + pos); //unit is seconds + + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Video_Seek; + msg.obj = pos; + MainApp.broadcastMessage(msg); + } + else { + //get method to get the duration and position of the playback + long duration = 0; + long curPos = 0; + + if (!VideoPlayerActivity.isVideoActivityFinished()) { + duration = VideoPlayerActivity.getDuration(); + curPos = VideoPlayerActivity.getCurrentPosition(); + duration = duration < 0 ? 0 : duration; + curPos = curPos < 0 ? 0 : curPos; + Log.d(tag, "airplay get method scrub: duration = " + duration + "; position = " + curPos); + + //Milliseconds need to be converted to seconds + DecimalFormat decimalFormat = new DecimalFormat(".000000"); + String strDuration = decimalFormat.format(duration / 1000f); + String strCurPos = decimalFormat.format(curPos / 1000f); + + //must have space, duration: **.******, or else, apple client can not syn with android + String returnStr = "duration: " + strDuration + "\nposition: " + strCurPos; + Log.d(tag, "airplay return scrub message = " + returnStr); + returnBody = new StringEntity(returnStr, "UTF-8"); + } + else { + //After the interface for playing videos exits, the mobile terminal must also exit. + httpContext.setAttribute(Constant.Need_sendReverse, Constant.Status.Status_stop); + httpContext.setAttribute(Constant.ReverseMsg, Constant.getStopEventMsg(1, sessionId, Constant.Status.Status_stop)); + } + } + + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.setHeader("Date", new Date().toString()); + httpResponse.setEntity(returnBody); + } + else if (target.startsWith(Constant.Target.RATE)) { //Set playback rate (special case: 0 is pause) + String value = StringUtils.getQueryStringValue(target, "?value="); + if (!value.isEmpty()) { + float rate = new Float(value); + Log.d(tag, "airplay rate = " + rate); + + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Video_Rate; + msg.obj = rate; + MainApp.broadcastMessage(msg); + } + + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.setHeader("Date", new Date().toString()); + } + //IOS 8.4.1 Never send this command (Youku does not send, Tencent video sends) + else if (target.equalsIgnoreCase(Constant.Target.PLAYBACK_INFO)) { + Log.d(tag, "airplay received playback_info request"); + String playback_info = ""; + long duration = 0; + long cacheDuration = 0; + long curPos = 0; + + String status = Constant.Status.Status_stop; + + if (VideoPlayerActivity.isVideoActivityFinished()) { + Log.d(tag, " airplay video activity is finished"); + status = Constant.Status.Status_stop; + + httpContext.setAttribute(Constant.Need_sendReverse, status); + httpContext.setAttribute(Constant.ReverseMsg, Constant.getStopEventMsg(1, sessionId, status)); + } + else { + curPos = VideoPlayerActivity.getCurrentPosition(); + duration = VideoPlayerActivity.getDuration(); + cacheDuration = curPos; + if (curPos == -1 || duration == -1 || cacheDuration == -1) { + status = Constant.Status.Status_load; + playback_info = Constant.getPlaybackInfo(0, 0, 0, 0); + } + else { + status = Constant.Status.Status_play; + playback_info = Constant.getPlaybackInfo(duration / 1000f, cacheDuration / 1000f, curPos / 1000f, 1); + } + + httpContext.setAttribute(Constant.Need_sendReverse, status); + httpContext.setAttribute(Constant.ReverseMsg, Constant.getVideoEventMsg(sessionId, status)); + } + + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.addHeader("Date", new Date().toString()); + httpResponse.addHeader("Content-Type", "text/x-apple-plist+xml"); + httpResponse.setEntity(new StringEntity(playback_info)); + } + else if (target.equals("/fp-setup")) { + Log.d(tag, "airplay setup content = " + new String(entityContent, "UTF-8")); + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.addHeader("Date", new Date().toString()); + } + // ======================================================================= + // non-standard extended API methods: + // ======================================================================= + else if (target.equals(Constant.Target.QUEUE)) { //Add video to end of queue + String playUrl = ""; + String referUrl = ""; + Double startPos = 0.0; + + requestBody = new String(entityContent); + Log.d(tag, " airplay play action request content = " + requestBody); + //a video from iPhone + if (contentType.equalsIgnoreCase("application/x-apple-binary-plist")) { + // + HashMap map = BplistParser.parse(entityContent); + playUrl = (String) map.get("Content-Location"); + referUrl = (String) map.get("Referer"); + startPos = (Double) map.get("Start-Position"); + } + else { + //iTunes pushed videos or Youku + playUrl = StringUtils.getRequestBodyValue(requestBody, "Content-Location:"); + referUrl = StringUtils.getRequestBodyValue(requestBody, "Referer:"); + String v = StringUtils.getRequestBodyValue(requestBody, "Start-Position:"); + startPos = (v.isEmpty()) ? 0.0 : Double.valueOf(v); + } + Log.d(tag, "airplay playUrl = " + playUrl + "; start Pos = " + startPos + "; referer = " + referUrl); + + Message msg = Message.obtain(); + HashMap map = new HashMap(); + map.put(Constant.PlayURL, playUrl); + map.put(Constant.RefererURL, referUrl); + map.put(Constant.Start_Pos, Double.toString(startPos)); + msg.what = Constant.Msg.Msg_Video_Queue; + msg.obj = map; + MainApp.broadcastMessage(msg); + + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.setHeader("Date", new Date().toString()); + } + else if (target.equals(Constant.Target.NEXT)) { //skip forward to next video in queue + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Video_Next; + MainApp.broadcastMessage(msg); + + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.addHeader("Date", new Date().toString()); + } + else if (target.equals(Constant.Target.PREVIOUS)) { //skip backward to previous video in queue + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Video_Prev; + MainApp.broadcastMessage(msg); + + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.addHeader("Date", new Date().toString()); + } + else if (target.startsWith(Constant.Target.VOLUME)) { //set audio volume (special case: 0 is mute) + String value = StringUtils.getQueryStringValue(target, "?value="); + if (!value.isEmpty()) { + float audioVolume = new Float(value); + Log.d(tag, "airplay volume = " + audioVolume); + + Message msg = Message.obtain(); + msg.what = Constant.Msg.Msg_Audio_Volume; + msg.obj = audioVolume; + MainApp.broadcastMessage(msg); + } + + httpResponse.setStatusCode(HttpStatus.SC_OK); + httpResponse.setHeader("Date", new Date().toString()); + } + // ======================================================================= + else { + Log.d(tag, "airplay default not process"); + } + } + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/NetworkingService.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/NetworkingService.java new file mode 100644 index 0000000..944c078 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/NetworkingService.java @@ -0,0 +1,391 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.service; + +import java.io.IOException; +import java.lang.ref.WeakReference; +import java.net.InetAddress; +import java.util.HashMap; +import java.util.Locale; + +import javax.jmdns.JmDNS; +import javax.jmdns.ServiceInfo; + +import android.app.Notification; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.net.wifi.WifiManager; +import android.os.Build; +import android.os.Handler; +import android.os.IBinder; +import android.os.Message; +import android.util.Log; +import android.view.Gravity; +import android.widget.RemoteViews; +import android.widget.Toast; + +import com.github.warren_bank.exoplayer_airplay_receiver.R; +import com.github.warren_bank.exoplayer_airplay_receiver.MainApp; +import com.github.warren_bank.exoplayer_airplay_receiver.constant.Constant; +import com.github.warren_bank.exoplayer_airplay_receiver.httpcore.RequestListenerThread; +import com.github.warren_bank.exoplayer_airplay_receiver.ui.ImageActivity; +import com.github.warren_bank.exoplayer_airplay_receiver.ui.VideoPlayerActivity; +import com.github.warren_bank.exoplayer_airplay_receiver.utils.NetworkUtils; + +public class NetworkingService extends Service { + private static final int NOTIFICATION_ID = 1; + private static final String ACTION_STOP = "STOP"; + + private static final String tag = NetworkingService.class.getSimpleName(); + private static final String airplayType = "._airplay._tcp.local"; + private static final String raopType = "._raop._tcp.local"; + + private String airplayName = "ExoPlayer AirPlay Receiver"; + private ServiceHandler handler; + private InetAddress localAddress; + private JmDNS jmdnsAirplay = null; + private JmDNS jmdnsRaop; + private ServiceInfo airplayService = null; + private ServiceInfo raopService; + + private HashMap values = new HashMap(); + private String preMac; + + private RequestListenerThread thread; + + private WifiManager.MulticastLock lock; + + @Override + public void onCreate() { + super.onCreate(); + Log.d(tag, "register service onCreate"); + + WifiManager wifi = (android.net.wifi.WifiManager) getSystemService(android.content.Context.WIFI_SERVICE); + lock = wifi.createMulticastLock("mylockthereturn"); + lock.setReferenceCounted(true); + lock.acquire(); + + handler = new ServiceHandler(NetworkingService.this); + MainApp.registerHandler(NetworkingService.class.getName(), handler); + + Toast toast = android.widget.Toast.makeText(getApplicationContext(), "Registering Airplay service...", android.widget.Toast.LENGTH_SHORT); + toast.setGravity(Gravity.CENTER, 0, 0); + toast.show(); + showNotification(); + + airplayName = android.os.Build.MODEL + "@" + airplayName; + + new Thread() { + public void run() { + try { + thread = new RequestListenerThread(); + thread.setDaemon(false); + thread.start(); + + registerAirplay(); + } + catch (IOException e) { + e.printStackTrace(); + Message msg = Message.obtain(); + msg.what = Constant.Register.FAIL; + MainApp.broadcastMessage(msg); + return; + } + } + }.start(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + onStart(intent, startId); + return START_STICKY; + } + + @Override + public void onStart(Intent intent, int startId) { + processIntent(intent); + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(tag, "NetworkingService onDestroy"); + + shutdown(false); + } + + private void shutdown(boolean useForce) { + if (lock == null) return; + + if (lock.isHeld()) lock.release(); + lock = null; + + MainApp.unregisterHandler(NetworkingService.class.getName()); + hideNotification(); + + new Thread() { + public void run() { + try { + if (thread != null) { + thread.destroy(); + thread = null; + } + + unregisterAirplay(); + } + catch (Exception e) { + e.printStackTrace(); + } + finally { + if (useForce) stopSelf(); + } + } + }.start(); + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private void registerAirplay() throws IOException { + Message msg = Message.obtain(); + if (!getParams()) { + msg.what = Constant.Register.FAIL; + } + else { + register(); + msg.what = Constant.Register.OK; + Log.d(tag, "airplay register airplay success"); + } + MainApp.broadcastMessage(msg); + } + + private void register() throws IOException { + Log.d(tag, "airplay register"); + registerTcpLocal(); + registerRaopLocal(); + } + + private void registerTcpLocal() throws IOException { + airplayService = ServiceInfo.create(airplayName + airplayType, airplayName, Constant.AIRPLAY_PORT, 0, 0, values); + jmdnsAirplay = JmDNS.create(localAddress); //create must bind IP address (android 4.0+) + jmdnsAirplay.registerService(airplayService); + } + + private void registerRaopLocal() throws IOException { + String raopName = preMac + "@" + airplayName; + raopService = ServiceInfo.create(raopName + raopType, raopName, Constant.RAOP_PORT, "tp=UDP sm=false sv=false ek=1 et=0,1 cn=0,1 ch=2 ss=16 sr=44100 pw=false vn=3 da=true md=0,1,2 vs=103.14 txtvers=1"); + jmdnsRaop = JmDNS.create(localAddress); + jmdnsRaop.registerService(raopService); + } + + private boolean getParams() { + String strMac = null; + + try { + Thread.sleep(2 * 1000); + } + catch (InterruptedException e) { + e.printStackTrace(); + } + + localAddress = NetworkUtils.getLocalIpAddress(); //Get local IP object + if (localAddress == null) { + Log.d(tag, "local address = null"); + return false; + } + String[] str_Array = new String[2]; + try { + str_Array = NetworkUtils.getMACAddress(localAddress); + if (str_Array == null) + return false; + strMac = str_Array[0].toUpperCase(Locale.ENGLISH); + preMac = str_Array[1].toUpperCase(Locale.ENGLISH); + } + catch (Exception e) { + e.printStackTrace(); + return false; + } + Log.d(tag, "airplay registered. Airplay Mac address:" + strMac + "; preMac = " + preMac); + + values.put("deviceid", strMac); + values.put("features", "0x297f"); + values.put("model", "AppleTV2,1"); + values.put("srcvers", "130.14"); + + return true; + } + + private void unregisterAirplay() { + Log.d(tag, "un register airplay service"); + + if (jmdnsAirplay != null) { + try { + jmdnsAirplay.unregisterService(airplayService); + jmdnsAirplay.close(); + } + catch (IOException e) { + e.printStackTrace(); + } + finally { + jmdnsAirplay = null; + } + } + + if (jmdnsRaop != null) { + try { + jmdnsRaop.unregisterService(raopService); + jmdnsRaop.close(); + } + catch (IOException e) { + e.printStackTrace(); + } + finally { + jmdnsRaop = null; + } + } + } + + // ------------------------------------------------------------------------- + // foregrounding.. + + private void showNotification() { + Notification notification = getNotification(); + + if (Build.VERSION.SDK_INT >= 5) { + startForeground(NOTIFICATION_ID, notification); + } + else { + NotificationManager NM = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + NM.notify(NOTIFICATION_ID, notification); + } + } + + private void hideNotification() { + if (Build.VERSION.SDK_INT >= 5) { + stopForeground(true); + } + else { + NotificationManager NM = (NotificationManager) getSystemService(NOTIFICATION_SERVICE); + NM.cancel(NOTIFICATION_ID); + } + } + + private Notification getNotification() { + Notification notification = new Notification(); + notification.when = System.currentTimeMillis(); + notification.flags = 0; + notification.flags |= Notification.FLAG_ONGOING_EVENT; + notification.flags |= Notification.FLAG_NO_CLEAR; + notification.icon = R.drawable.launcher; + notification.tickerText = getString(R.string.notification_service_ticker); + notification.contentIntent = getPendingIntent_StopService(); + notification.deleteIntent = getPendingIntent_StopService(); + + if (Build.VERSION.SDK_INT >= 16) { + notification.priority = Notification.PRIORITY_HIGH; + } + else { + notification.flags |= Notification.FLAG_HIGH_PRIORITY; + } + + RemoteViews contentView = new RemoteViews(getPackageName(), R.layout.service_notification); + contentView.setImageViewResource(R.id.notification_icon, R.drawable.launcher); + contentView.setTextViewText(R.id.notification_text_line1, getString(R.string.notification_service_content_line1)); + contentView.setTextViewText(R.id.notification_text_line2, getString(R.string.notification_service_content_line2)); + notification.contentView = contentView; + + return notification; + } + + private PendingIntent getPendingIntent_StopService() { + Intent intent = new Intent(NetworkingService.this, NetworkingService.class); + intent.setAction(ACTION_STOP); + + return PendingIntent.getService(NetworkingService.this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); + } + + // ------------------------------------------------------------------------- + // process inbound intents + + private void processIntent(Intent intent) { + if (intent == null) return; + + String action = intent.getAction(); + if (action == ACTION_STOP) + shutdown(true); + } + + // ------------------------------------------------------------------------- + // message handler + + private static class ServiceHandler extends Handler { + private WeakReference weakReference; + + public ServiceHandler(NetworkingService service) { + weakReference = new WeakReference(service); + } + + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + NetworkingService service = weakReference.get(); + if (service == null) + return; + switch (msg.what) { + case Constant.Register.OK : { + Toast toast = Toast.makeText(service.getApplicationContext(), "Airplay registration success", android.widget.Toast.LENGTH_SHORT); + toast.setGravity(Gravity.CENTER, 0, 0); + toast.show(); + } + break; + case Constant.Register.FAIL : { + Toast toast = Toast.makeText(service.getApplicationContext(), "Airplay registration failed", android.widget.Toast.LENGTH_SHORT); + toast.setGravity(Gravity.CENTER, 0, 0); + toast.show(); + service.stopSelf(); + android.os.Process.killProcess(android.os.Process.myPid()); //Quit the program completely + break; + } + case Constant.Msg.Msg_Photo : { + byte[] pic = (byte[]) msg.obj; + Intent intent = new Intent(service, ImageActivity.class); + intent.putExtra("picture", pic); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + service.startActivity(intent); + break; + } + case Constant.Msg.Msg_Video_Play : { + HashMap map = (HashMap) msg.obj; + String playUrl = map.get(Constant.PlayURL); + String startPos = map.get(Constant.Start_Pos); + + Intent intent = new Intent(service, VideoPlayerActivity.class); + intent.putExtra("mode", "play"); + intent.putExtra("uri", playUrl); + intent.putExtra("startPosition", Double.valueOf(startPos)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + service.startActivity(intent); + break; + } + case Constant.Msg.Msg_Video_Queue : { + HashMap map = (HashMap) msg.obj; + String playUrl = map.get(Constant.PlayURL); + String referUrl = map.get(Constant.RefererURL); + String startPos = map.get(Constant.Start_Pos); + + Intent intent = new Intent(service, VideoPlayerActivity.class); + intent.putExtra("mode", "queue"); + intent.putExtra("uri", playUrl); + intent.putExtra("referer", referUrl); + intent.putExtra("startPosition", Double.valueOf(startPos)); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + service.startActivity(intent); + break; + } + + } + } + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/ImageActivity.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/ImageActivity.java new file mode 100644 index 0000000..c59c62c --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/ImageActivity.java @@ -0,0 +1,116 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.ui; + +import java.lang.ref.WeakReference; + +import android.app.Activity; +import android.content.Intent; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.util.Log; +import android.view.Window; +import android.view.WindowManager; +import android.widget.ImageView; + +import com.github.warren_bank.exoplayer_airplay_receiver.R; +import com.github.warren_bank.exoplayer_airplay_receiver.MainApp; +import com.github.warren_bank.exoplayer_airplay_receiver.constant.Constant; +import com.github.warren_bank.exoplayer_airplay_receiver.httpcore.RequestListenerThread; + +public class ImageActivity extends Activity { + private static final String tag = ImageActivity.class.getSimpleName(); + private ImageView iv; + private ImageHandler handler; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + this.requestWindowFeature(Window.FEATURE_NO_TITLE); + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); + setContentView(R.layout.activity_image); + + handler = new ImageHandler(ImageActivity.this); + MainApp.registerHandler(ImageActivity.class.getName(), handler); + + initView(); + } + + private void initView() { + iv = (ImageView) findViewById(R.id.image_view); + Intent intent = getIntent(); + if (intent != null) { + byte[] pic = intent.getByteArrayExtra("picture"); + this.showImage(pic); + } + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if (intent != null) { + byte[] pic = intent.getByteArrayExtra("picture"); + this.showImage(pic); + } + } + + private void showImage(byte[] pic) { + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inPreferQualityOverSpeed = true; //Improve picture quality + + int size = (options.outWidth * options.outHeight); + int size_limit = 1920 * 1080 * 4; + if (size > 1920 * 1080 * 4) { + int zoomRate = (int) Math.ceil(size * 1.0 / size_limit); + if (zoomRate <= 0) + zoomRate = 1; + options.inSampleSize = zoomRate; + } + + if (!Thread.currentThread().isInterrupted()) { + options.inJustDecodeBounds = false; + Bitmap bitmap = BitmapFactory.decodeByteArray(pic, 0, pic.length, options); + iv.setImageBitmap(bitmap); + } + } + + public void onBackPressed() { + super.onBackPressed(); + } + + public void onDestroy() { + super.onDestroy(); + Log.d(tag, "airplay ImageActivity onDestroy"); + RequestListenerThread.photoCacheMaps.clear(); + MainApp.unregisterHandler(ImageActivity.class.getName()); + } + + private static class ImageHandler extends Handler { + private WeakReference imageActivityWeakReference; + + public ImageHandler(ImageActivity activity) { + imageActivityWeakReference = new WeakReference(activity); + } + + @Override + public void handleMessage(Message msg) { + final ImageActivity activity = this.imageActivityWeakReference.get(); + + if (activity == null) + return; + if (activity.isFinishing()) + return; + + switch (msg.what) { + case Constant.Msg.Msg_Stop : + activity.finish(); + break; + case Constant.Msg.Msg_Video_Play : + activity.finish(); + break; + } + } + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/StartNetworkingServiceActivity.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/StartNetworkingServiceActivity.java new file mode 100644 index 0000000..4e0423d --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/StartNetworkingServiceActivity.java @@ -0,0 +1,32 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.ui; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import com.github.warren_bank.exoplayer_airplay_receiver.R; +import com.github.warren_bank.exoplayer_airplay_receiver.MainApp; +import com.github.warren_bank.exoplayer_airplay_receiver.service.NetworkingService; +import com.github.warren_bank.exoplayer_airplay_receiver.utils.NetworkUtils; + +public class StartNetworkingServiceActivity extends Activity { + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (!NetworkUtils.isWifiConnected(MainApp.getInstance())) { + finish(); + return; + } + + setContentView(R.layout.activity_start_networking_service); + startListenService(); + onBackPressed(); + } + + private void startListenService() { + Intent intent = new Intent(getApplicationContext(), NetworkingService.class); + MainApp.getInstance().startService(intent); + finish(); + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/VideoPlayerActivity.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/VideoPlayerActivity.java new file mode 100644 index 0000000..31dda19 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/VideoPlayerActivity.java @@ -0,0 +1,130 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.ui; + +import java.lang.ref.WeakReference; + +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.util.Log; + +import com.github.warren_bank.exoplayer_airplay_receiver.MainApp; +import com.github.warren_bank.exoplayer_airplay_receiver.constant.Constant; +import com.github.warren_bank.exoplayer_airplay_receiver.ui.exoplayer2.VideoActivity; + +public class VideoPlayerActivity extends VideoActivity { + private static final String tag = "VideoPlayerActivity"; + + private static volatile boolean isVideoActivityFinished = false; + private static volatile long duration = 0l; + private static volatile long curPosition = 0l; + + private Handler handler; + private Handler timer; + private Runnable timerTask; + + public static boolean isVideoActivityFinished() { + return isVideoActivityFinished; + } + + public static long getDuration() { + return duration; + } + + public static long getCurrentPosition() { + return curPosition; + } + + @Override + public void onCreate(Bundle icicle) { + super.onCreate(icicle); + + handler = new VideoHandler(this); + MainApp.registerHandler(VideoPlayerActivity.class.getName(), handler); + + timerTask = new Runnable() { + @Override + public void run() { + isVideoActivityFinished = ((playerManager == null) || (playerManager.isPlayerReady() == false)); + + if (isVideoActivityFinished) { + duration = 0l; + curPosition = 0l; + } + else { + duration = playerManager.getCurrentVideoDuration(); + curPosition = playerManager.getCurrentVideoPosition(); + } + + // scheduleAtFixedRate + timer.postDelayed(this, 1 * 1000); + } + }; + + timer = new Handler(); + timer.postDelayed(timerTask, 1 * 1000); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + Log.d(tag, "airplay VideoPlayerActivity onDestroy"); + + if (timer != null) { + timer.removeCallbacks(timerTask); + timer = null; + } + + MainApp.unregisterHandler(VideoPlayerActivity.class.getName()); + } + + private static class VideoHandler extends Handler { + private WeakReference weakReference; + + public VideoHandler(VideoPlayerActivity activity) { + weakReference = new WeakReference(activity); + } + + @Override + public void handleMessage(Message msg) { + super.handleMessage(msg); + + VideoPlayerActivity activity = weakReference.get(); + if (activity == null) + return; + if (activity.isFinishing()) + return; + if (activity.playerManager == null) + return; + + switch (msg.what) { + case Constant.Msg.Msg_Video_Seek : + float positionSec = (float) msg.obj; + activity.playerManager.AirPlay_scrub(positionSec); + break; + case Constant.Msg.Msg_Video_Rate : + float rate = (float) msg.obj; + activity.playerManager.AirPlay_rate(rate); + break; + case Constant.Msg.Msg_Stop : + activity.playerManager.AirPlay_stop(); + //activity.finish(); + break; + case Constant.Msg.Msg_Photo : + activity.finish(); + break; + + case Constant.Msg.Msg_Video_Next : + activity.playerManager.AirPlay_next(); + break; + case Constant.Msg.Msg_Video_Prev : + activity.playerManager.AirPlay_previous(); + break; + case Constant.Msg.Msg_Audio_Volume : + float audioVolume = (float) msg.obj; + activity.playerManager.AirPlay_volume(audioVolume); + break; + } + + } + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/ExoPlayerEventLogger.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/ExoPlayerEventLogger.java new file mode 100644 index 0000000..cf365aa --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/ExoPlayerEventLogger.java @@ -0,0 +1,490 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.ui.exoplayer2; + +import android.os.SystemClock; +import android.util.Log; +import android.view.Surface; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.Format; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.RendererCapabilities; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.audio.AudioRendererEventListener; +import com.google.android.exoplayer2.decoder.DecoderCounters; +import com.google.android.exoplayer2.drm.DefaultDrmSessionEventListener; +import com.google.android.exoplayer2.metadata.Metadata; +import com.google.android.exoplayer2.metadata.MetadataOutput; +import com.google.android.exoplayer2.metadata.emsg.EventMessage; +import com.google.android.exoplayer2.metadata.id3.ApicFrame; +import com.google.android.exoplayer2.metadata.id3.CommentFrame; +import com.google.android.exoplayer2.metadata.id3.GeobFrame; +import com.google.android.exoplayer2.metadata.id3.Id3Frame; +import com.google.android.exoplayer2.metadata.id3.PrivFrame; +import com.google.android.exoplayer2.metadata.id3.TextInformationFrame; +import com.google.android.exoplayer2.metadata.id3.UrlLinkFrame; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.MediaSourceEventListener; +import com.google.android.exoplayer2.source.TrackGroup; +import com.google.android.exoplayer2.source.TrackGroupArray; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector; +import com.google.android.exoplayer2.trackselection.MappingTrackSelector.MappedTrackInfo; +import com.google.android.exoplayer2.trackselection.TrackSelection; +import com.google.android.exoplayer2.trackselection.TrackSelectionArray; +import com.google.android.exoplayer2.video.VideoRendererEventListener; + +import java.io.IOException; +import java.text.NumberFormat; +import java.util.Locale; + + +public final class ExoPlayerEventLogger implements Player.EventListener, MetadataOutput, + AudioRendererEventListener, VideoRendererEventListener, MediaSourceEventListener, + DefaultDrmSessionEventListener { + + private static final String TAG = "ExoPlayerEventLogger"; + private static final int MAX_TIMELINE_ITEM_LINES = 3; + private static final NumberFormat TIME_FORMAT; + + static { + TIME_FORMAT = NumberFormat.getInstance(Locale.US); + TIME_FORMAT.setMinimumFractionDigits(2); + TIME_FORMAT.setMaximumFractionDigits(2); + TIME_FORMAT.setGroupingUsed(false); + } + + private final MappingTrackSelector trackSelector; + private final Timeline.Window window; + private final Timeline.Period period; + private final long startTimeMs; + + public ExoPlayerEventLogger(MappingTrackSelector trackSelector) { + this.trackSelector = trackSelector; + window = new Timeline.Window(); + period = new Timeline.Period(); + startTimeMs = SystemClock.elapsedRealtime(); + } + + // Player.EventListener + + @Override + public void onLoadingChanged(boolean isLoading) { + Log.d(TAG, "loading [" + isLoading + "]"); + } + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int state) { + Log.d(TAG, "state [" + getSessionTimeString() + ", " + playWhenReady + ", " + + getStateString(state) + "]"); + } + + @Override + public void onRepeatModeChanged(@Player.RepeatMode int repeatMode) { + Log.d(TAG, "repeatMode [" + getRepeatModeString(repeatMode) + "]"); + } + + @Override + public void onShuffleModeEnabledChanged(boolean shuffleModeEnabled) { + Log.d(TAG, "shuffleModeEnabled [" + shuffleModeEnabled + "]"); + } + + @Override + public void onPositionDiscontinuity(@Player.DiscontinuityReason int reason) { + Log.d(TAG, "positionDiscontinuity [" + getDiscontinuityReasonString(reason) + "]"); + } + + @Override + public void onPlaybackParametersChanged(PlaybackParameters playbackParameters) { + Log.d(TAG, "playbackParameters " + String.format( + "[speed=%.2f, pitch=%.2f]", playbackParameters.speed, playbackParameters.pitch)); + } + + @Override + public void onPlayerError(ExoPlaybackException e) { + Log.e(TAG, "playerFailed [" + getSessionTimeString() + "]", e); + } + + @Override + public void onTracksChanged(TrackGroupArray ignored, TrackSelectionArray trackSelections) { + MappedTrackInfo mappedTrackInfo = trackSelector.getCurrentMappedTrackInfo(); + if (mappedTrackInfo == null) { + Log.d(TAG, "Tracks []"); + return; + } + Log.d(TAG, "Tracks ["); + // Log tracks associated to renderers. + for (int rendererIndex = 0; rendererIndex < mappedTrackInfo.length; rendererIndex++) { + TrackGroupArray rendererTrackGroups = mappedTrackInfo.getTrackGroups(rendererIndex); + TrackSelection trackSelection = trackSelections.get(rendererIndex); + if (rendererTrackGroups.length > 0) { + Log.d(TAG, " Renderer:" + rendererIndex + " ["); + for (int groupIndex = 0; groupIndex < rendererTrackGroups.length; groupIndex++) { + TrackGroup trackGroup = rendererTrackGroups.get(groupIndex); + String adaptiveSupport = getAdaptiveSupportString(trackGroup.length, + mappedTrackInfo.getAdaptiveSupport(rendererIndex, groupIndex, false)); + Log.d(TAG, " Group:" + groupIndex + ", adaptive_supported=" + adaptiveSupport + " ["); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + String status = getTrackStatusString(trackSelection, trackGroup, trackIndex); + String formatSupport = getFormatSupportString( + mappedTrackInfo.getTrackFormatSupport(rendererIndex, groupIndex, trackIndex)); + Log.d(TAG, " " + status + " Track:" + trackIndex + ", " + + Format.toLogString(trackGroup.getFormat(trackIndex)) + + ", supported=" + formatSupport); + } + Log.d(TAG, " ]"); + } + // Log metadata for at most one of the tracks selected for the renderer. + if (trackSelection != null) { + for (int selectionIndex = 0; selectionIndex < trackSelection.length(); selectionIndex++) { + Metadata metadata = trackSelection.getFormat(selectionIndex).metadata; + if (metadata != null) { + Log.d(TAG, " Metadata ["); + printMetadata(metadata, " "); + Log.d(TAG, " ]"); + break; + } + } + } + Log.d(TAG, " ]"); + } + } + // Log tracks not associated with a renderer. + TrackGroupArray unassociatedTrackGroups = mappedTrackInfo.getUnassociatedTrackGroups(); + if (unassociatedTrackGroups.length > 0) { + Log.d(TAG, " Renderer:None ["); + for (int groupIndex = 0; groupIndex < unassociatedTrackGroups.length; groupIndex++) { + Log.d(TAG, " Group:" + groupIndex + " ["); + TrackGroup trackGroup = unassociatedTrackGroups.get(groupIndex); + for (int trackIndex = 0; trackIndex < trackGroup.length; trackIndex++) { + String status = getTrackStatusString(false); + String formatSupport = getFormatSupportString( + RendererCapabilities.FORMAT_UNSUPPORTED_TYPE); + Log.d(TAG, " " + status + " Track:" + trackIndex + ", " + + Format.toLogString(trackGroup.getFormat(trackIndex)) + + ", supported=" + formatSupport); + } + Log.d(TAG, " ]"); + } + Log.d(TAG, " ]"); + } + Log.d(TAG, "]"); + } + + @Override + public void onSeekProcessed() { + Log.d(TAG, "seekProcessed"); + } + + // MetadataOutput + + @Override + public void onMetadata(Metadata metadata) { + Log.d(TAG, "onMetadata ["); + printMetadata(metadata, " "); + Log.d(TAG, "]"); + } + + // AudioRendererEventListener + + @Override + public void onAudioEnabled(DecoderCounters counters) { + Log.d(TAG, "audioEnabled [" + getSessionTimeString() + "]"); + } + + @Override + public void onAudioSessionId(int audioSessionId) { + Log.d(TAG, "audioSessionId [" + audioSessionId + "]"); + } + + @Override + public void onAudioDecoderInitialized(String decoderName, long elapsedRealtimeMs, + long initializationDurationMs) { + Log.d(TAG, "audioDecoderInitialized [" + getSessionTimeString() + ", " + decoderName + "]"); + } + + @Override + public void onAudioInputFormatChanged(Format format) { + Log.d(TAG, "audioFormatChanged [" + getSessionTimeString() + ", " + Format.toLogString(format) + + "]"); + } + + @Override + public void onAudioDisabled(DecoderCounters counters) { + Log.d(TAG, "audioDisabled [" + getSessionTimeString() + "]"); + } + + @Override + public void onAudioSinkUnderrun(int bufferSize, long bufferSizeMs, long elapsedSinceLastFeedMs) { + printInternalError("audioTrackUnderrun [" + bufferSize + ", " + bufferSizeMs + ", " + + elapsedSinceLastFeedMs + "]", null); + } + + // VideoRendererEventListener + + @Override + public void onVideoEnabled(DecoderCounters counters) { + Log.d(TAG, "videoEnabled [" + getSessionTimeString() + "]"); + } + + @Override + public void onVideoDecoderInitialized(String decoderName, long elapsedRealtimeMs, + long initializationDurationMs) { + Log.d(TAG, "videoDecoderInitialized [" + getSessionTimeString() + ", " + decoderName + "]"); + } + + @Override + public void onVideoInputFormatChanged(Format format) { + Log.d(TAG, "videoFormatChanged [" + getSessionTimeString() + ", " + Format.toLogString(format) + + "]"); + } + + @Override + public void onVideoDisabled(DecoderCounters counters) { + Log.d(TAG, "videoDisabled [" + getSessionTimeString() + "]"); + } + + @Override + public void onDroppedFrames(int count, long elapsed) { + Log.d(TAG, "droppedFrames [" + getSessionTimeString() + ", " + count + "]"); + } + + @Override + public void onVideoSizeChanged(int width, int height, int unappliedRotationDegrees, + float pixelWidthHeightRatio) { + Log.d(TAG, "videoSizeChanged [" + width + ", " + height + "]"); + } + + @Override + public void onRenderedFirstFrame(Surface surface) { + Log.d(TAG, "renderedFirstFrame [" + surface + "]"); + } + + // DefaultDrmSessionEventListener + + @Override + public void onDrmSessionManagerError(Exception e) { + printInternalError("drmSessionManagerError", e); + } + + @Override + public void onDrmKeysRestored() { + Log.d(TAG, "drmKeysRestored [" + getSessionTimeString() + "]"); + } + + @Override + public void onDrmKeysRemoved() { + Log.d(TAG, "drmKeysRemoved [" + getSessionTimeString() + "]"); + } + + @Override + public void onDrmKeysLoaded() { + Log.d(TAG, "drmKeysLoaded [" + getSessionTimeString() + "]"); + } + + //MediaSourceEventListener + + @Override + public void onTimelineChanged(Timeline timeline, Object manifest, int reason) { + int periodCount = timeline.getPeriodCount(); + int windowCount = timeline.getWindowCount(); + Log.d(TAG, "sourceInfo [periodCount=" + periodCount + ", windowCount=" + windowCount); + for (int i = 0; i < Math.min(periodCount, MAX_TIMELINE_ITEM_LINES); i++) { + timeline.getPeriod(i, period); + Log.d(TAG, " " + "period [" + getTimeString(period.getDurationMs()) + "]"); + } + if (periodCount > MAX_TIMELINE_ITEM_LINES) { + Log.d(TAG, " ..."); + } + for (int i = 0; i < Math.min(windowCount, MAX_TIMELINE_ITEM_LINES); i++) { + timeline.getWindow(i, window); + Log.d(TAG, " " + "window [" + getTimeString(window.getDurationMs()) + ", " + + window.isSeekable + ", " + window.isDynamic + "]"); + } + if (windowCount > MAX_TIMELINE_ITEM_LINES) { + Log.d(TAG, " ..."); + } + Log.d(TAG, "]"); + } + + @Override + public void onMediaPeriodCreated(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + + } + + @Override + public void onMediaPeriodReleased(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + + } + + @Override + public void onLoadStarted(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + + } + + @Override + public void onLoadCompleted(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + + } + + @Override + public void onLoadCanceled(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData) { + + } + + @Override + public void onLoadError(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, LoadEventInfo loadEventInfo, MediaLoadData mediaLoadData, IOException error, boolean wasCanceled) { + printInternalError("loadError", error); + } + + @Override + public void onReadingStarted(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId) { + + } + + @Override + public void onUpstreamDiscarded(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + + } + + @Override + public void onDownstreamFormatChanged(int windowIndex, MediaSource.MediaPeriodId mediaPeriodId, MediaLoadData mediaLoadData) { + + } + + // Internal methods + + private void printInternalError(String type, Exception e) { + Log.e(TAG, "internalError [" + getSessionTimeString() + ", " + type + "]", e); + } + + private void printMetadata(Metadata metadata, String prefix) { + for (int i = 0; i < metadata.length(); i++) { + Metadata.Entry entry = metadata.get(i); + if (entry instanceof TextInformationFrame) { + TextInformationFrame textInformationFrame = (TextInformationFrame) entry; + Log.d(TAG, prefix + String.format("%s: value=%s", textInformationFrame.id, + textInformationFrame.value)); + } else if (entry instanceof UrlLinkFrame) { + UrlLinkFrame urlLinkFrame = (UrlLinkFrame) entry; + Log.d(TAG, prefix + String.format("%s: url=%s", urlLinkFrame.id, urlLinkFrame.url)); + } else if (entry instanceof PrivFrame) { + PrivFrame privFrame = (PrivFrame) entry; + Log.d(TAG, prefix + String.format("%s: owner=%s", privFrame.id, privFrame.owner)); + } else if (entry instanceof GeobFrame) { + GeobFrame geobFrame = (GeobFrame) entry; + Log.d(TAG, prefix + String.format("%s: mimeType=%s, filename=%s, description=%s", + geobFrame.id, geobFrame.mimeType, geobFrame.filename, geobFrame.description)); + } else if (entry instanceof ApicFrame) { + ApicFrame apicFrame = (ApicFrame) entry; + Log.d(TAG, prefix + String.format("%s: mimeType=%s, description=%s", + apicFrame.id, apicFrame.mimeType, apicFrame.description)); + } else if (entry instanceof CommentFrame) { + CommentFrame commentFrame = (CommentFrame) entry; + Log.d(TAG, prefix + String.format("%s: language=%s, description=%s", commentFrame.id, + commentFrame.language, commentFrame.description)); + } else if (entry instanceof Id3Frame) { + Id3Frame id3Frame = (Id3Frame) entry; + Log.d(TAG, prefix + String.format("%s", id3Frame.id)); + } else if (entry instanceof EventMessage) { + EventMessage eventMessage = (EventMessage) entry; + Log.d(TAG, prefix + String.format("EMSG: scheme=%s, id=%d, value=%s", + eventMessage.schemeIdUri, eventMessage.id, eventMessage.value)); + } + } + } + + private String getSessionTimeString() { + return getTimeString(SystemClock.elapsedRealtime() - startTimeMs); + } + + private static String getTimeString(long timeMs) { + return timeMs == C.TIME_UNSET ? "?" : TIME_FORMAT.format((timeMs) / 1000f); + } + + private static String getStateString(int state) { + switch (state) { + case Player.STATE_BUFFERING: + return "B"; + case Player.STATE_ENDED: + return "E"; + case Player.STATE_IDLE: + return "I"; + case Player.STATE_READY: + return "R"; + default: + return "?"; + } + } + + private static String getFormatSupportString(int formatSupport) { + switch (formatSupport) { + case RendererCapabilities.FORMAT_HANDLED: + return "YES"; + case RendererCapabilities.FORMAT_EXCEEDS_CAPABILITIES: + return "NO_EXCEEDS_CAPABILITIES"; + case RendererCapabilities.FORMAT_UNSUPPORTED_DRM: + return "NO_UNSUPPORTED_DRM"; + case RendererCapabilities.FORMAT_UNSUPPORTED_SUBTYPE: + return "NO_UNSUPPORTED_TYPE"; + case RendererCapabilities.FORMAT_UNSUPPORTED_TYPE: + return "NO"; + default: + return "?"; + } + } + + private static String getAdaptiveSupportString(int trackCount, int adaptiveSupport) { + if (trackCount < 2) { + return "N/A"; + } + switch (adaptiveSupport) { + case RendererCapabilities.ADAPTIVE_SEAMLESS: + return "YES"; + case RendererCapabilities.ADAPTIVE_NOT_SEAMLESS: + return "YES_NOT_SEAMLESS"; + case RendererCapabilities.ADAPTIVE_NOT_SUPPORTED: + return "NO"; + default: + return "?"; + } + } + + private static String getTrackStatusString(TrackSelection selection, TrackGroup group, + int trackIndex) { + return getTrackStatusString(selection != null && selection.getTrackGroup() == group + && selection.indexOf(trackIndex) != C.INDEX_UNSET); + } + + private static String getTrackStatusString(boolean enabled) { + return enabled ? "[X]" : "[ ]"; + } + + private static String getRepeatModeString(@Player.RepeatMode int repeatMode) { + switch (repeatMode) { + case Player.REPEAT_MODE_OFF: + return "OFF"; + case Player.REPEAT_MODE_ONE: + return "ONE"; + case Player.REPEAT_MODE_ALL: + return "ALL"; + default: + return "?"; + } + } + + private static String getDiscontinuityReasonString(@Player.DiscontinuityReason int reason) { + switch (reason) { + case Player.DISCONTINUITY_REASON_PERIOD_TRANSITION: + return "PERIOD_TRANSITION"; + case Player.DISCONTINUITY_REASON_SEEK: + return "SEEK"; + case Player.DISCONTINUITY_REASON_SEEK_ADJUSTMENT: + return "SEEK_ADJUSTMENT"; + case Player.DISCONTINUITY_REASON_INTERNAL: + return "INTERNAL"; + default: + return "?"; + } + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/PlayerManager.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/PlayerManager.java new file mode 100644 index 0000000..465b176 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/PlayerManager.java @@ -0,0 +1,524 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.ui.exoplayer2; + +import com.github.warren_bank.exoplayer_airplay_receiver.R; + +import android.content.Context; +import android.net.Uri; +import androidx.annotation.Nullable; +import android.view.KeyEvent; + +import com.google.android.exoplayer2.C; +import com.google.android.exoplayer2.DefaultRenderersFactory; +import com.google.android.exoplayer2.ExoPlaybackException; +import com.google.android.exoplayer2.ExoPlayerFactory; +import com.google.android.exoplayer2.PlaybackParameters; +import com.google.android.exoplayer2.Player; +import com.google.android.exoplayer2.Player.DiscontinuityReason; +import com.google.android.exoplayer2.Player.EventListener; +import com.google.android.exoplayer2.Player.TimelineChangeReason; +import com.google.android.exoplayer2.RenderersFactory; +import com.google.android.exoplayer2.SimpleExoPlayer; +import com.google.android.exoplayer2.Timeline; +import com.google.android.exoplayer2.source.ConcatenatingMediaSource; +import com.google.android.exoplayer2.source.ExtractorMediaSource; +import com.google.android.exoplayer2.source.MediaSource; +import com.google.android.exoplayer2.source.dash.DashMediaSource; +import com.google.android.exoplayer2.source.hls.HlsMediaSource; +import com.google.android.exoplayer2.source.smoothstreaming.SsMediaSource; +import com.google.android.exoplayer2.trackselection.DefaultTrackSelector; +import com.google.android.exoplayer2.ui.PlayerView; +import com.google.android.exoplayer2.util.MimeTypes; +import com.google.android.exoplayer2.upstream.DefaultDataSourceFactory; +import com.google.android.exoplayer2.upstream.DefaultHttpDataSourceFactory; +import com.google.android.exoplayer2.upstream.RawResourceDataSource; + +import java.util.ArrayList; + +/** Manages players and an internal media queue */ +public final class PlayerManager implements EventListener { + + private PlayerView playerView; + private ArrayList mediaQueue; + private ConcatenatingMediaSource concatenatingMediaSource; + private SimpleExoPlayer exoPlayer; + private DefaultHttpDataSourceFactory httpDataSourceFactory; + private DefaultDataSourceFactory rawDataSourceFactory; + private int currentItemIndex; + + /** + * @param context A {@link Context}. + * @param playerView The {@link PlayerView} for local playback. + */ + public static PlayerManager createPlayerManager( + Context context, + PlayerView playerView + ) { + PlayerManager playerManager = new PlayerManager(context, playerView); + playerManager.init(); + return playerManager; + } + + private PlayerManager( + Context context, + PlayerView playerView + ) { + this.playerView = playerView; + this.mediaQueue = new ArrayList<>(); + this.concatenatingMediaSource = new ConcatenatingMediaSource(); + + DefaultTrackSelector trackSelector = new DefaultTrackSelector(); + RenderersFactory renderersFactory = new DefaultRenderersFactory(context); + this.exoPlayer = ExoPlayerFactory.newSimpleInstance(context, renderersFactory, trackSelector); + this.exoPlayer.addListener(this); + this.playerView.setKeepContentOnPlayerReset(false); + this.playerView.setPlayer(this.exoPlayer); + + ExoPlayerEventLogger exoLogger = new ExoPlayerEventLogger(trackSelector); + this.exoPlayer.addListener(exoLogger); + + String userAgent = context.getResources().getString(R.string.user_agent); + this.httpDataSourceFactory = new DefaultHttpDataSourceFactory(userAgent); + this.rawDataSourceFactory = new DefaultDataSourceFactory(context, userAgent); + + this.currentItemIndex = C.INDEX_UNSET; + } + + // Query state of player. + + /** + * @return Is the instance of ExoPlayer able to immediately play from its current position. + */ + public boolean isPlayerReady() { + if (exoPlayer == null) + return false; + + int state = exoPlayer.getPlaybackState(); + return (state == Player.STATE_READY); + } + + /** + * @return The duration of the current video in milliseconds, or -1 if the duration is not known. + */ + public long getCurrentVideoDuration() { + if (exoPlayer == null) + return 0l; + + long durationMs = exoPlayer.getDuration(); + if (durationMs == C.TIME_UNSET) durationMs = -1l; + return durationMs; + } + + /** + * @return The position of the current video in milliseconds. + */ + public long getCurrentVideoPosition() { + if (exoPlayer == null) + return 0l; + + long positionMs = exoPlayer.getCurrentPosition(); + return positionMs; + } + + // Queue manipulation methods. + + /** + * Plays a specified queue item in the current player. + * + * @param itemIndex The index of the item to play. + */ + public void selectQueueItem(int itemIndex) { + setCurrentItem(itemIndex, true); + } + + /** + * @return The index of the currently played item. + */ + public int getCurrentItemIndex() { + return currentItemIndex; + } + + /** + * Appends {@link VideoSource} to the media queue. + * + * @param uri The URL to a video file or stream. + * @param mimeType The mime-type of the video file or stream. + * @param referer The URL to include in the 'Referer' HTTP header of requests to retrieve the video file or stream. + * @param startPosition The position at which to start playback within the video file or (non-live) stream. When value < 1.0, it is interpreted to mean a percentage of the total video length. When value >= 1.0, it is interpreted to mean a fixed offset in seconds. + */ + public void addItem( + String uri, + String mimeType, + String referer, + float startPosition + ) { + VideoSource sample = VideoSource.createVideoSource(uri, mimeType, referer, startPosition); + addItem(sample); + } + + /** + * Appends {@code sample} to the media queue. + * + * @param sample The {@link VideoSource} to append. + */ + public void addItem(VideoSource sample) { + if ((sample.uri == null) || sample.uri.isEmpty()) return; + + mediaQueue.add(sample); + concatenatingMediaSource.addMediaSource(buildMediaSource(sample)); + } + + /** + * @return The size of the media queue. + */ + public int getMediaQueueSize() { + return mediaQueue.size(); + } + + /** + * Returns the item at the given index in the media queue. + * + * @param position The index of the item. + * @return The item at the given index in the media queue. + */ + public VideoSource getItem(int position) { + return (getMediaQueueSize() > position) + ? mediaQueue.get(position) + : null; + } + + /** + * Removes the item at the given index from the media queue. + * + * @param itemIndex The index of the item to remove. + * @return Whether the removal was successful. + */ + public boolean removeItem(int itemIndex) { + concatenatingMediaSource.removeMediaSource(itemIndex); + mediaQueue.remove(itemIndex); + if ((itemIndex == currentItemIndex) && (itemIndex == mediaQueue.size())) { + maybeSetCurrentItemAndNotify(C.INDEX_UNSET); + } else if (itemIndex < currentItemIndex) { + maybeSetCurrentItemAndNotify(currentItemIndex - 1); + } + return true; + } + + /** + * Moves an item within the queue. + * + * @param fromIndex The index of the item to move. + * @param toIndex The target index of the item in the queue. + * @return Whether the item move was successful. + */ + public boolean moveItem(int fromIndex, int toIndex) { + // Player update. + concatenatingMediaSource.moveMediaSource(fromIndex, toIndex); + + mediaQueue.add(toIndex, mediaQueue.remove(fromIndex)); + + // Index update. + if (fromIndex == currentItemIndex) { + maybeSetCurrentItemAndNotify(toIndex); + } else if (fromIndex < currentItemIndex && toIndex >= currentItemIndex) { + maybeSetCurrentItemAndNotify(currentItemIndex - 1); + } else if (fromIndex > currentItemIndex && toIndex <= currentItemIndex) { + maybeSetCurrentItemAndNotify(currentItemIndex + 1); + } + + return true; + } + + // AirPlay functionality (exposed by HTTP endpoints) + // http://nto.github.io/AirPlay.html#video + + /** + * Clears the media queue and adds the specified {@link VideoSource}. + * + * @param uri The URL to a video file or stream. + * @param startPosition The position at which to start playback within the video file or (non-live) stream. When value < 1.0, it is interpreted to mean a percentage of the total video length. When value >= 1.0, it is interpreted to mean a fixed offset in seconds. + */ + public void AirPlay_play(String uri, float startPosition) { + resetQueue(); + + addItem(uri, null, null, startPosition); + + selectQueueItem(0); + exoPlayer.previous(); + } + + /** + * Seek within the current video. + * + * @param positionSec The position as a fixed offset in seconds. + */ + public void AirPlay_scrub(float positionSec) { + if (exoPlayer.isCurrentWindowSeekable()) { + long positionMs = ((long) positionSec) * 1000; + exoPlayer.seekTo(currentItemIndex, positionMs); + } + } + + /** + * Change rate of speed for video playback. + * + * @param rate New rate of speed for video playback. The value 0.0 is equivalent to 'pause'. + */ + public void AirPlay_rate(float rate) { + if (rate == 0f) { + // pause playback + if (exoPlayer.isPlaying()) + exoPlayer.setPlayWhenReady(false); + } + else { + // update playback speed + exoPlayer.setPlaybackParameters( + new PlaybackParameters(rate) + ); + + // resume playback if paused + if (!exoPlayer.isPlaying()) + exoPlayer.setPlayWhenReady(true); + } + } + + /** + * Stop video playback and reset the player. + */ + public void AirPlay_stop() { + resetQueue(); + + addRawVideoItem(R.raw.airplay); + + selectQueueItem(0); + exoPlayer.previous(); + } + + // extra non-standard functionality (exposed by HTTP endpoints) + + /** + * Appends {@link VideoSource} to the media queue. + * + * @param uri The URL to a video file or stream. + * @param referer The URL to include in the 'Referer' HTTP header of requests to retrieve the video file or stream. + * @param startPosition The position at which to start playback within the video file or (non-live) stream. When value < 1.0, it is interpreted to mean a percentage of the total video length. When value >= 1.0, it is interpreted to mean a fixed offset in seconds. + */ + public void AirPlay_queue( + String uri, + String referer, + float startPosition + ) { + addItem(uri, null, referer, startPosition); + } + + /** + * Skip forward to the next {@link VideoSource} in the media queue. + */ + public void AirPlay_next() { + if (exoPlayer.hasNext()) { + exoPlayer.next(); + } + } + + /** + * Skip backward to the previous {@link VideoSource} in the media queue. + */ + public void AirPlay_previous() { + if (exoPlayer.hasPrevious()) { + exoPlayer.previous(); + } + } + + /** + * Change audio volume level. + * + * @param audioVolume New rate audio volume level. The range of acceptable values is 0.0 to 1.0. The value 0.0 is equivalent to 'mute'. The value 1.0 is unity gain. + */ + public void AirPlay_volume(float audioVolume) { + exoPlayer.setVolume(audioVolume); // range of values: 0.0 (mute) - 1.0 (unity gain) + } + + // Miscellaneous methods. + + /** + * Dispatches a given {@link KeyEvent} to the corresponding view of the current player. + * + * @param event The {@link KeyEvent}. + * @return Whether the event was handled by the target view. + */ + public boolean dispatchKeyEvent(KeyEvent event) { + return playerView.dispatchKeyEvent(event); + } + + /** + * Releases the manager and instance of ExoPlayer that it holds. + */ + public void release() { + try { + release_exoPlayer(); + + mediaQueue.clear(); + concatenatingMediaSource.clear(); + + mediaQueue = null; + concatenatingMediaSource = null; + httpDataSourceFactory = null; + rawDataSourceFactory = null; + currentItemIndex = C.INDEX_UNSET; + } + catch (Exception e){} + } + + /** + * Releases the instance of ExoPlayer. + */ + private void release_exoPlayer() { + try { + playerView.setPlayer(null); + exoPlayer.removeListener(this); + exoPlayer.stop(true); + exoPlayer.release(); + + playerView = null; + exoPlayer = null; + } + catch (Exception e){} + } + + // Player.EventListener implementation. + + @Override + public void onPlayerStateChanged(boolean playWhenReady, int playbackState) { + updateCurrentItemIndex(); + } + + @Override + public void onPositionDiscontinuity(@DiscontinuityReason int reason) { + updateCurrentItemIndex(); + } + + @Override + public void onTimelineChanged( + Timeline timeline, @Nullable Object manifest, @TimelineChangeReason int reason + ){ + updateCurrentItemIndex(); + } + + @Override + public void onPlayerError(ExoPlaybackException error) { + if (error.type == ExoPlaybackException.TYPE_SOURCE) { + exoPlayer.next(); + } + } + + // Internal methods. + + private void init() { + // Media queue management. + exoPlayer.prepare(concatenatingMediaSource); + } + + private void resetQueue() { + mediaQueue.clear(); + concatenatingMediaSource.clear(); + currentItemIndex = C.INDEX_UNSET; + } + + private void updateCurrentItemIndex() { + int playbackState = exoPlayer.getPlaybackState(); + + int currentItemIndex = ((playbackState != Player.STATE_IDLE) && (playbackState != Player.STATE_ENDED)) + ? exoPlayer.getCurrentWindowIndex() + : C.INDEX_UNSET; + + maybeSetCurrentItemAndNotify(currentItemIndex); + } + + /** + * Starts playback of the item at the given position. + * + * @param itemIndex The index of the item to play. + * @param playWhenReady Whether the player should proceed when ready to do so. + */ + private void setCurrentItem(int itemIndex, boolean playWhenReady) { + maybeSetCurrentItemAndNotify(itemIndex); + exoPlayer.setPlayWhenReady(playWhenReady); + } + + private void maybeSetCurrentItemAndNotify(int currentItemIndex) { + if (this.currentItemIndex != currentItemIndex) { + this.currentItemIndex = currentItemIndex; + + if (currentItemIndex != C.INDEX_UNSET) { + seekToStartPosition(currentItemIndex); + setHttpRequestHeaders(currentItemIndex); + } + } + } + + private void seekToStartPosition(int currentItemIndex) { + VideoSource sample = getItem(currentItemIndex); + if (sample == null) return; + + float position = sample.startPosition; + + if ((position > 0f) && (position < 1f)) { + // percentage + long duration = exoPlayer.getDuration(); // ms + if (duration != C.TIME_UNSET) { + long positionMs = (long) (duration * position); + exoPlayer.seekTo(currentItemIndex, positionMs); + } + } + else if (position >= 1f) { + // fixed offset in seconds + long positionMs = ((long) position) * 1000; + exoPlayer.seekTo(currentItemIndex, positionMs); + } + } + + private void setHttpRequestHeaders(int currentItemIndex) { + VideoSource sample = getItem(currentItemIndex); + if (sample == null) return; + + if ((sample.referer != null) && !sample.referer.isEmpty()) { + Uri referer = Uri.parse(sample.referer); + String origin = referer.getScheme() + "://" + referer.getAuthority(); + + setHttpRequestHeader("origin", origin); + setHttpRequestHeader("referer", sample.referer); + } + } + + private void setHttpRequestHeader(String name, String value) { + httpDataSourceFactory.getDefaultRequestProperties().set(name, value); + } + + private MediaSource buildMediaSource(VideoSource sample) { + Uri uri = Uri.parse(sample.uri); + + switch (sample.mimeType) { + case MimeTypes.APPLICATION_M3U8: + return new HlsMediaSource.Factory(httpDataSourceFactory).createMediaSource(uri); + case MimeTypes.APPLICATION_MPD: + return new DashMediaSource.Factory(httpDataSourceFactory).createMediaSource(uri); + case MimeTypes.APPLICATION_SS: + return new SsMediaSource.Factory(httpDataSourceFactory).createMediaSource(uri); + default: + return new ExtractorMediaSource.Factory(httpDataSourceFactory).createMediaSource(uri); + } + } + + private MediaSource buildRawVideoMediaSource(int rawResourceId) { + Uri uri = RawResourceDataSource.buildRawResourceUri(rawResourceId); + + return new ExtractorMediaSource.Factory(rawDataSourceFactory).createMediaSource(uri); + } + + private void addRawVideoItem(int rawResourceId) { + VideoSource sample = VideoSource.createVideoSource("raw", "raw", null, 0f); + + mediaQueue.add(sample); + concatenatingMediaSource.addMediaSource(buildRawVideoMediaSource(rawResourceId)); + } + +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoActivity.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoActivity.java new file mode 100644 index 0000000..5357b4a --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoActivity.java @@ -0,0 +1,95 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.ui.exoplayer2; + +import com.github.warren_bank.exoplayer_airplay_receiver.R; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; +import android.util.Log; +import android.view.KeyEvent; +import android.view.Window; +import android.view.WindowManager; + +import com.google.android.exoplayer2.ui.PlayerView; + +public class VideoActivity extends Activity { + private static final String tag = "VideoActivity"; + + private PlayerView playerView; + public PlayerManager playerManager; + + // Activity lifecycle methods. + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + requestWindowFeature(Window.FEATURE_NO_TITLE); + getWindow().setFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN, WindowManager.LayoutParams.FLAG_FULLSCREEN); + setContentView(R.layout.activity_video); + + playerView = (PlayerView) findViewById(R.id.player_view); + playerView.requestFocus(); + + playerManager = PlayerManager.createPlayerManager(/* context= */ this, playerView); + + handleIntent(getIntent()); + } + + @Override + public void onNewIntent(Intent intent) { + handleIntent(intent); + } + + @Override + protected void onStop() { + super.onStop(); + if (playerManager != null) { + playerManager.release(); + playerManager = null; + } + finish(); + } + + // Activity input. + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // If the event was not handled then see if the player view can handle it. + return super.dispatchKeyEvent(event) || playerManager.dispatchKeyEvent(event); + } + + // Internal methods. + + private void handleIntent(Intent intent) { + if (intent == null) + return; + + if (isFinishing()) { + setIntent(intent); + recreate(); + return; + } + + if (playerManager == null) + return; + + String mode = intent.getStringExtra("mode"); + String uri = intent.getStringExtra("uri"); + String referer = intent.getStringExtra("referer"); + float startPosition = (float) intent.getDoubleExtra("startPosition", 0); + + if (uri == null) + return; + + if ((mode != null) && mode.equals("queue")) { + playerManager.AirPlay_queue(uri, referer, startPosition); + Log.d(tag, "queue video: url = " + uri + "; position = " + startPosition + "; referer = " + referer); + } + else /* if ((mode != null) && mode.equals("play")) */ { + playerManager.AirPlay_play(uri, startPosition); + Log.d(tag, "play video: url = " + uri + "; position = " + startPosition); + } + } + +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoSource.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoSource.java new file mode 100644 index 0000000..5fb5862 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/ui/exoplayer2/VideoSource.java @@ -0,0 +1,117 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.ui.exoplayer2; + +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +final class VideoSource { + + public final String uri; + public final String mimeType; + public final String referer; + public final float startPosition; + + // static factory + + public static VideoSource createVideoSource( + String uri, + String mimeType, + String referer, + float startPosition + ) { + VideoSource videoSource = new VideoSource(uri, mimeType, referer, startPosition); + return videoSource; + } + + private VideoSource( + String uri, + String mimeType, + String referer, + float startPosition + ) { + if ((mimeType == null) || mimeType.isEmpty()) { + mimeType = VideoSource.get_mimeType(uri); + } + + this.uri = uri; + this.mimeType = mimeType; + this.referer = referer; + this.startPosition = startPosition; + } + + // Public methods. + + @Override + public String toString() { + return uri; + } + + // Static helpers. + + public static Pattern video_regex = Pattern.compile("\\.(mp4|mp4v|mpv|m1v|m4v|mpg|mpg2|mpeg|xvid|webm|3gp|avi|mov|mkv|ogg|ogv|ogm|m3u8|mpd|ism(?:[vc]|/manifest)?)(?:[\\?#]|$)"); + + public static String get_mimeType(String uri) { + if (uri == null) return null; + + Matcher matcher = VideoSource.video_regex.matcher(uri.toLowerCase()); + String file_ext = ""; + String mimeType = ""; + + if (matcher.find()) { + file_ext = matcher.group(1); + + switch (file_ext) { + case "mp4": + case "mp4v": + case "m4v": + mimeType = "video/mp4"; + break; + case "mpv": + mimeType = "video/MPV"; + break; + case "m1v": + case "mpg": + case "mpg2": + case "mpeg": + mimeType = "video/mpeg"; + break; + case "xvid": + mimeType = "video/x-xvid"; + break; + case "webm": + mimeType = "video/webm"; + break; + case "3gp": + mimeType = "video/3gpp"; + break; + case "avi": + mimeType = "video/x-msvideo"; + break; + case "mov": + mimeType = "video/quicktime"; + break; + case "mkv": + mimeType = "video/x-mkv"; + break; + case "ogg": + case "ogv": + case "ogm": + mimeType = "video/ogg"; + break; + case "m3u8": + mimeType = "application/x-mpegURL"; + break; + case "mpd": + mimeType = "application/dash+xml"; + break; + case "ism": + case "ism/manifest": + case "ismv": + case "ismc": + mimeType = "application/vnd.ms-sstr+xml"; + break; + } + } + return mimeType; + } + +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/utils/BplistParser.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/utils/BplistParser.java new file mode 100644 index 0000000..d9a12cc --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/utils/BplistParser.java @@ -0,0 +1,66 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.utils; + +import java.util.HashMap; + +import android.util.Log; + +import com.dd.plist.NSDictionary; +import com.dd.plist.NSNumber; +import com.dd.plist.NSObject; +import com.dd.plist.NSString; +import com.dd.plist.PropertyListParser; + +public class BplistParser { + private static final String tag = BplistParser.class.getSimpleName(); + + public static HashMap parse(byte[] plistbytes) { + HashMap map = new HashMap(); + + try { + NSDictionary rootDict = (NSDictionary) PropertyListParser.parse(plistbytes); + + String[] keys = rootDict.allKeys(); + + for (int i = 0; i < keys.length; i++) { + Log.d(tag, "airplay parser entity key is=" + keys[i]); + + NSObject object = rootDict.objectForKey(keys[i]); + if (object.getClass().equals(NSNumber.class)) { + NSNumber num = (NSNumber) object; + Log.d(tag, "airplay parser value Type is" + num.type() + " and value is=" + rootDict.objectForKey(keys[i])); + switch (num.type()) { + case NSNumber.BOOLEAN : { + boolean bool = num.boolValue(); + map.put(keys[i], new Boolean(bool)); + break; + } + case NSNumber.INTEGER : { + long l = num.longValue(); + map.put(keys[i], new Long(l)); + break; + } + case NSNumber.REAL : { + double d = num.doubleValue(); + map.put(keys[i], new Double(d)); + break; + } + } + } + else if (object.getClass().equals(NSString.class)) { + map.put(keys[i], object.toString()); + Log.d(tag, "airplay parser Value is Type of String and values is =" + rootDict.objectForKey(keys[i])); + } + else { + map.put(keys[i], object); + Log.d(tag, "airplay parser values is Type of =" + rootDict.objectForKey(keys[i])); + } + } + } + catch (Exception e) { + e.printStackTrace(); + return null; + } + + return map; + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/utils/NetworkUtils.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/utils/NetworkUtils.java new file mode 100644 index 0000000..cf27fe8 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/utils/NetworkUtils.java @@ -0,0 +1,92 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.utils; + +import java.net.Inet4Address; +import java.net.InetAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; +import android.net.wifi.WifiInfo; +import android.net.wifi.WifiManager; + +public class NetworkUtils { + + public synchronized static Inet4Address getLocalIpAddress() { + try { + for (Enumeration en = NetworkInterface + .getNetworkInterfaces(); en.hasMoreElements();) { + NetworkInterface intf = en.nextElement(); + for (Enumeration enumIpAddr = intf.getInetAddresses(); enumIpAddr + .hasMoreElements();) { + InetAddress inetAddress = enumIpAddr.nextElement(); + if (!inetAddress.isLoopbackAddress()) { + if (inetAddress instanceof Inet4Address) { + return ((Inet4Address) inetAddress); + } + } + } + } + } + catch (SocketException ex) { + } + return null; + } + + public synchronized static String[] getMACAddress(InetAddress ia) throws Exception { + //Obtain the network interface object (that is, the network card), and get the mac address. The mac address exists in a byte array. + byte[] mac = NetworkInterface.getByInetAddress(ia).getHardwareAddress(); + + //The following code assembles the mac address into a String + String[] str_array = new String[2]; + StringBuffer sb1 = new StringBuffer(); + StringBuffer sb2 = new StringBuffer(); + + for (int i = 0; i < mac.length; i++) { + if (i != 0) { + sb1.append(":"); + } + //mac[i] & 0xFF ..to convert bytes into positive integers + String s = Integer.toHexString(mac[i] & 0xFF); + sb1.append(s.length() == 1 ? 0 + s : s); + sb2.append(s.length() == 1 ? 0 + s : s); + } + //Change all lowercase letters of the string to regular mac addresses and return + str_array[0] = sb1.toString(); + str_array[1] = sb2.toString(); + return str_array; + //return sb1.toString().toUpperCase(); + } + + public static String getLocalIp(Context context) { + //Get wifi service + WifiManager wifiManager = (WifiManager) context + .getSystemService(Context.WIFI_SERVICE); + //Determine if wifi is on + if (!wifiManager.isWifiEnabled()) { + wifiManager.setWifiEnabled(true); + } + WifiInfo wifiInfo = wifiManager.getConnectionInfo(); + int ipAddress = wifiInfo.getIpAddress(); + String ip = intToIp(ipAddress); + + return ip; + } + + public static boolean isWifiConnected(Context context) { + ConnectivityManager manager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + //Get status + NetworkInfo.State wifi = manager.getNetworkInfo(ConnectivityManager.TYPE_WIFI).getState(); + //Determine the conditions of wifi connection + if (wifi == NetworkInfo.State.CONNECTED) + return true; + else + return false; + } + + private static String intToIp(int i) { + return (i & 0xFF) + "." + ((i >> 8) & 0xFF) + "." + ((i >> 16) & 0xFF) + "." + (i >> 24 & 0xFF); + } +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/utils/StringUtils.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/utils/StringUtils.java new file mode 100644 index 0000000..bd6b2d2 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/utils/StringUtils.java @@ -0,0 +1,41 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.utils; + +public class StringUtils { + + public static String getValue(String textBlock, String prefix, String suffix) { + String value = ""; + + if ((prefix == null) || prefix.isEmpty()) + return value; + + int indexStart, indexEnd; + + indexStart = textBlock.indexOf(prefix); + if (indexStart < 0) + return value; + indexStart += prefix.length(); + + indexEnd = ((suffix == null) || suffix.isEmpty()) + ? -1 + : textBlock.indexOf(suffix, indexStart); + + value = (indexEnd < 0) + ? textBlock.substring(indexStart) + : textBlock.substring(indexStart, indexEnd); + value = value.trim(); + + return value; + } + + public static String getRequestBodyValue(String requestBody, String prefix) { + // note: LFs in requestBody are escaped (not sure why) + String suffix = "\\n"; + return StringUtils.getValue(requestBody, prefix, suffix); + } + + public static String getQueryStringValue(String url, String prefix) { + String suffix = "&"; + return StringUtils.getValue(url, prefix, suffix); + } + +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/drawable/launcher.png b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/drawable/launcher.png new file mode 100644 index 0000000..e29857f Binary files /dev/null and b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/drawable/launcher.png differ diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_image.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_image.xml new file mode 100644 index 0000000..dc7b521 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_image.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_start_networking_service.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_start_networking_service.xml new file mode 100644 index 0000000..850c9a1 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_start_networking_service.xml @@ -0,0 +1,6 @@ + + + diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_video.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_video.xml new file mode 100644 index 0000000..39e9261 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/activity_video.xml @@ -0,0 +1,14 @@ + + + + + + diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/service_notification.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/service_notification.xml new file mode 100644 index 0000000..57aad1c --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/layout/service_notification.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/raw/airplay.mp4 b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/raw/airplay.mp4 new file mode 100644 index 0000000..cec8181 Binary files /dev/null and b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/raw/airplay.mp4 differ diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/values/strings.xml b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/values/strings.xml new file mode 100644 index 0000000..aae2047 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/values/strings.xml @@ -0,0 +1,10 @@ + + ExoAirPlayer + + Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36 + + + ExoPlayer AirPlay Receiver: service running + ExoAirPlayer + Click to stop service. + diff --git a/android-studio-project/build.gradle b/android-studio-project/build.gradle new file mode 100644 index 0000000..d53b99b --- /dev/null +++ b/android-studio-project/build.gradle @@ -0,0 +1,21 @@ +buildscript { + repositories { + google() + jcenter() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.3.0' // https://mvnrepository.com/artifact/com.android.tools.build/gradle?repo=google + classpath 'com.google.android.gms:strict-version-matcher-plugin:1.1.0' // https://mvnrepository.com/artifact/com.google.android.gms/strict-version-matcher-plugin + } +} + +allprojects { + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/android-studio-project/constants.gradle b/android-studio-project/constants.gradle new file mode 100644 index 0000000..1e83e22 --- /dev/null +++ b/android-studio-project/constants.gradle @@ -0,0 +1,14 @@ +project.ext { + releaseVersionCode = Integer.parseInt("001000016", 10) + releaseVersion = '001.00.00-16API' + minSdkVersion = 16 + targetSdkVersion = 28 + compileSdkVersion = 28 + buildToolsVersion = '28.0.3' + javaVersion = JavaVersion.VERSION_1_8 + libVersionAndroidX = '1.1.0' + libVersionExoPlayer = '2.10.8' + libVersionDdPlist = '1.23' + libVersionHttpCore = '4.4.13' + libVersionJmDNS = '3.5.5' +} diff --git a/android-studio-project/gradle/wrapper/gradle-wrapper.jar b/android-studio-project/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/android-studio-project/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android-studio-project/gradle/wrapper/gradle-wrapper.properties b/android-studio-project/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..b10568f --- /dev/null +++ b/android-studio-project/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,5 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.10.1-all.zip diff --git a/android-studio-project/gradlew b/android-studio-project/gradlew new file mode 100644 index 0000000..9d82f78 --- /dev/null +++ b/android-studio-project/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android-studio-project/gradlew.bat b/android-studio-project/gradlew.bat new file mode 100644 index 0000000..8a0b282 --- /dev/null +++ b/android-studio-project/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/android-studio-project/settings.gradle b/android-studio-project/settings.gradle new file mode 100644 index 0000000..da470fa --- /dev/null +++ b/android-studio-project/settings.gradle @@ -0,0 +1 @@ +include ':ExoPlayer-AirPlay-Receiver' diff --git a/credits/1. [gpfduoduo] AirPlay-Receiver-on-Android.txt b/credits/1. [gpfduoduo] AirPlay-Receiver-on-Android.txt new file mode 100644 index 0000000..0e62fdb --- /dev/null +++ b/credits/1. [gpfduoduo] AirPlay-Receiver-on-Android.txt @@ -0,0 +1,149 @@ +---------------------------------------------------------------------------------------------------- + +design notes: +============= + +https://github.com/gpfduoduo/AirPlay-Receiver-on-Android + +---------------------------------------------------------------------------------------------------- + +app/src/main/java/com/guo/duoduo/airplayreceiver/ui/MainActivity.java + Intent intent = new Intent(getApplicationContext(), RegisterService.class); + startService(intent); + finish(); + +---------------------------------------------------------------------------------------------------- + +app/src/main/java/com/guo/duoduo/airplayreceiver/service/RegisterService.java + // httpd + // ===== + // * uses: + // httpcore/* + // * does not use: + // http/* + // httpProcess/* + thread = new RequestListenerThread(); + thread.setDaemon(false); + thread.start(); + + // Bonjour + // ======= + airplayService = ServiceInfo.create(airplayName + airplayType, airplayName, AIRPLAY_PORT, 0, 0, values); + jmdnsAirplay = JmDNS.create(localAddress); + jmdnsAirplay.registerService(airplayService); + + String raopName = preMac + "@" + airplayName; + raopService = ServiceInfo.create(raopName + raopType, raopName, RAOP_PORT, "tp=UDP sm=false sv=false ek=1 et=0,1 cn=0,1 ch=2 ss=16 " + "sr=44100 pw=false vn=3 da=true md=0,1,2 vs=103.14 txtvers=1"); + jmdnsRaop = JmDNS.create(localAddress); + jmdnsRaop.registerService(raopService); + +---------------------------------------------------------------------------------------------------- + +centralized: used to pass messages between Activities (ex: AirPlay commands received at HTTP endpoints) + +app/src/main/java/com/guo/duoduo/airplayreceiver/MyApplication.java + // static method returns instance of Application + private static MyApplication instance; + public void onCreate() {instance = this;} + public static MyApplication getInstance() {return instance;} + + // static method forwards a message to all handlers. All Activities register a handler when created, and unregister when destroyed. + private ConcurrentHashMap mHandlerMap = new ConcurrentHashMap(); + public ConcurrentHashMap getHandlerMap() {return mHandlerMap;} + public static void broadcastMessage(Message msg) { + for (Handler handler : getInstance().getHandlerMap().values()) {handler.sendMessage(Message.obtain(msg));} + } + +app/src/main/java/com/guo/duoduo/airplayreceiver/MyController.java + // convenience class used by Activities to encapsulate registering/unregistering a handler with the singleton Application + public MyController(String name, Handler handler) { + MyApplication.getInstance().getHandlerMap().put(name, handler); + this.mName = name; + } + public void destroy() { + MyApplication.getInstance().getHandlerMap().remove(mName); + } + +---------------------------------------------------------------------------------------------------- + +HTTPd: +====== + +app/src/main/java/com/guo/duoduo/airplayreceiver/httpcore/RequestListenerThread.java + - line: 162 + code: serversocket = new ServerSocket(RegisterService.AIRPLAY_PORT, 2, localAddress); + - line: 371 + code: if (target.equals(Constant.Target.REVERSE)) + docs: https://tools.ietf.org/html/draft-lentczner-rhttp-00 + spec: Reverse HTTP connection + - line: 476 + code: if (target.equals(Constant.Target.PLAY)) + +app/libs/httpcore-4.3.2.jar + libs: org.apache.http.* + repo: https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.3.2/ + https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.jar + https://repo1.maven.org/maven2/org/apache/httpcomponents/httpcore/4.3.2/httpcore-4.3.2.pom + repo: https://mvnrepository.com/artifact/org.apache.httpcomponents/httpcore + https://mvnrepository.com/artifact/org.apache.httpcomponents/httpcore/4.3.2 + https://mvnrepository.com/artifact/org.apache.httpcomponents/httpcore/4.4.13 + +app/src/main/java/com/guo/duoduo/airplayreceiver/service/RegisterService.java + - line: 322 + code: public void handleMessage(Message msg) + - line: 358 + code: case Constant.Msg.Msg_Video_Play : + note: passes data from message to an Intent that starts 'VideoPlayerActivity' + +notes: +====== +* only one standard library is needed: + implementation 'org.apache.httpcomponents:httpcore:4.4.13' +* the httpd server runs in a separate thread +* it processes all inbound requests and passes messages to registered handlers through the singleton Application +* the service always has a registered handler + - this is how a message can start a new Activity to display the requested content + - consideration: + * when an Activity is already started: + - the service will pass an Intent to the Activity: onNewIntent() + - the Activity will have its own registered handler, and receive the message from the singleton Application + - need to make sure the message isn't processed twice + +---------------------------------------------------------------------------------------------------- + +unused files to remove: +======================= + +http/* +httpProcess/* +rtsp/* +ui/VideoPlayerActivity_MediaPlayer.java +utils/Debug.java +utils/HostInterface.java +utils/ListenerList.java +utils/StringUtil.java + +features to remove: +=================== + +receiver/* + - initialization: + * AndroidManifest.xml + * MyApplication.onCreate() + - purpose: + * stops RegisterService when: + - disconnected from wifi + - screen turned off + * starts RegisterService when: + - reconnected to wifi + - screen turned on + - notes: + * does NOT kill: + - Application + - open Activities + * does NOT pass any message(s) to registered handlers + +RegisterService {public java.security.PrivateKey pk} + - unused variable + +---------------------------------------------------------------------------------------------------- diff --git a/credits/2. launcher icon.txt b/credits/2. launcher icon.txt new file mode 100644 index 0000000..bfebd26 --- /dev/null +++ b/credits/2. launcher icon.txt @@ -0,0 +1,6 @@ +filepath: android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/drawable/launcher.png +source: https://www.iconfinder.com/icons/3324974/airplay_icon#png-256 +license: Creative Commons (Attribution 3.0 Unported) + http://creativecommons.org/licenses/by/3.0/ +author: Cole Bemis + https://www.iconfinder.com/colebemis diff --git a/credits/3. 4-second animation when player is stopped.txt b/credits/3. 4-second animation when player is stopped.txt new file mode 100644 index 0000000..1c6b534 --- /dev/null +++ b/credits/3. 4-second animation when player is stopped.txt @@ -0,0 +1,9 @@ +filepath: android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/res/raw/airplay.mp4 +source: https://dribbble.com/shots/3118753-Airplay-Animation + http://cdn.dribbble.com/users/642610/screenshots/3118753/airplay1.gif?vid=1 +license: unclear. + https://dribbble.com/guidelines + website guidelines say: + "Please link back to Dribbble when posting Dribbble content elsewhere." +author: Dimitri Rivault + https://dribbble.com/drivault diff --git a/tests/01.sh b/tests/01.sh new file mode 100755 index 0000000..ddaf22f --- /dev/null +++ b/tests/01.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash + +# network address for running instance of 'ExoPlayer AirPlay Receiver' +airplay_ip='192.168.1.100:8192' + +# URLs for test videos: +videos_page='https://players.akamai.com/hls/' +video_url_1='https://multiplatform-f.akamaihd.net/i/multi/will/bunny/big_buck_bunny_,640x360_400,640x360_700,640x360_1000,950x540_1500,.f4v.csmil/master.m3u8' +video_url_2='https://multiplatform-f.akamaihd.net/i/multi/april11/hdworld/hdworld_,512x288_450_b,640x360_700_b,768x432_1000_b,1024x576_1400_m,.mp4.csmil/master.m3u8' +video_url_3='https://multiplatform-f.akamaihd.net/i/multi/april11/cctv/cctv_,512x288_450_b,640x360_700_b,768x432_1000_b,1024x576_1400_m,.mp4.csmil/master.m3u8' + +# play video #1 +curl -X POST \ + -H "Content-Type: text/parameters" \ + --data-binary "Content-Location: ${video_url_1}\nStart-Position: 0" \ + "http://${airplay_ip}/play" + +# seek to `30 seconds` within currently playing video +# note: POST body is required, even when it contains no data +curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/scrub?position=30.0" + +# pause the currently playing video +# note: POST body is required, even when it contains no data +curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/rate?value=0.0" + +# add video #2 to end of queue (set 'Referer' request header, seek to 50%) +# note: position < 1 is a percent of the total video length +curl -X POST \ + -H "Content-Type: text/parameters" \ + --data-binary "Content-Location: ${video_url_2}\nReferer: ${videos_page}\nStart-Position: 0.5" \ + "http://${airplay_ip}/queue" + +# add video #3 to end of queue (set 'Referer' request header, seek to 30 seconds) +# note: position >= 1 is a fixed offset (in seconds) +curl -X POST \ + -H "Content-Type: text/parameters" \ + --data-binary "Content-Location: ${video_url_3}\nReferer: ${videos_page}\nStart-Position: 30" \ + "http://${airplay_ip}/queue" + +# skip forward to next video in queue +# note: POST body is required, even when it contains no data +curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/next" + +# resume playback of the current video in queue at normal speed (ie: video #2 @ rate 1x) +# note: POST body is required, even when it contains no data +curl -X POST \ + --data-binary "" \ + "http://${airplay_ip}/rate?value=1.0"