Android app to run on a set-top box and play video URLs "cast" to it with a stateless HTTP API (based on AirPlay v1).
There is no UI when the app starts. It's a foreground service with a notification, which runs a web server on port 8192. The IP address of the server is given in the notification message.
When a video URL is "cast" to the server, a video player opens full-screen.
When an audio URL is "cast" to the server, the music plays in the background.. even when the screen is off.
When either audio or video media is playing and the player's window doesn't have focus (ex: listening to background audio, or by pressing the "home" button while watching a video), another notification is added to control playback or refocus the player's window.
This page is the simplest way to send signals to a running instance, though other "high level" tools exist to capture media URLs from the wild.
Audio or video files/playlists can also be started directly from the Android file system, which makes this app a very suitable replacement for a general-purpose video player.
Playlists can be generated dynamically from:
Playlists can be read explicitly from any text file with an .m3u
file extension,
which lists one media item path per line:
.m3u
file can be read from either the Android file system or a remote URLWhen a video file is played from the Android file system,
its directory is automatically scanned for matching subtitle file(s).
A match will have the same filename and any of the following extensions: srt,ttml,vtt,webvtt,ssa,ass
.
Nested extension(s) can optionally be used to distinguish between different languages (ex: .en-US.srt
, .es-MX.vtt
).
AirPlay v1 compatible APIs:
# network address for running instance of 'ExoPlayer AirPlay Receiver'
airplay_ip='192.168.1.100:8192'
# file path for test image (on sender):
image_path='/path/to/image.jpg'
# URL for test image:
image_page='https://commons.wikimedia.org/wiki/File:Android_robot.svg'
image_url='https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Android_robot.svg/654px-Android_robot.svg.png'
# URLs for test video:
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'
# URLs for test video text captions:
captions_page='https://github.com/gpac/gpac/tree/master/tests/media/webvtt'
caption_url_1='https://github.com/warren-bank/Android-ExoPlayer-AirPlay-Receiver/raw/v02/tests/.captions/counter.workaround-exoplayer-issue-7122.srt'
caption_url_2='https://github.com/warren-bank/Android-ExoPlayer-AirPlay-Receiver/raw/v02/tests/.captions/counter.vtt'
caption_url_3='https://github.com/gpac/gpac/raw/master/tests/media/webvtt/comments.vtt'
# URLs for test video DRM:
# https://exoplayer.dev/drm.html
# widevine: requires Android 4.4+
# clearkey: requires Android 5.0+
# playready: requires AndroidTV
drm_videos_page='https://github.com/google/ExoPlayer/blob/r2.14.0/demos/main/src/main/assets/media.exolist.json'
drm_video_url_1='https://storage.googleapis.com/wvmedia/cenc/h264/tears/tears.mpd'
drm_video_url_1_license_scheme='widevine'
drm_video_url_1_license_server='https://proxy.uat.widevine.com/proxy?provider=widevine_test'
drm_video_url_2='https://playready.directtaps.net/smoothstreaming/SSWSS720H264PR/SuperSpeedway_720.ism/Manifest'
drm_video_url_2_license_scheme='playready'
drm_video_url_2_license_server='https://playready.directtaps.net/pr/svc/rightsmanager.asmx'
# URLs for test audio:
audio_flac_nfo='https://archive.org/details/tntvillage_457399'
audio_flac_url='https://archive.org/download/tntvillage_457399/Black%20Sabbath%201970-2013/Studio%20Albums/1970%20Black%20Sabbath/1970%20Black%20Sabbath%20%5B1986%20France%20NELCD%206002%20Castle%5D/Black%20Sabbath%20-%20Black%20Sabbath%20%281986%2C%20Castle%20Communications%2C%20NELCD%206002%29.flac'
audio_m3u_page='https://archive.org/details/Mozart_Vesperae_Solennes_de_Confessore'
audio_mp3s_m3u='https://archive.org/download/Mozart_Vesperae_Solennes_de_Confessore/Mozart%20-%20Vesper%C3%A6%20Solennes%20de%20Confessore%20%28Cooke%29.m3u'
audio_htm_page='https://archive.org/details/tntvillage_455310'
audio_mp3s_htm='https://archive.org/download/tntvillage_455310/S%26G/Live/1967%20-%20Live%20From%20New%20York%20City%20%40320/'
# file paths for test media (on receiver):
video_path='/storage/external_SD/test-media/video/file.mp4'
subtt_path='/storage/external_SD/test-media/video/file.srt'
audio_path='/storage/external_SD/test-media/audio/file.mp3'
plist_path='/storage/external_SD/test-media/all audio and video files.m3u'
# directory paths for test media (on receiver):
video_dir_path='/storage/external_SD/test-media/video/'
audio_dir_path='/storage/external_SD/test-media/audio/'
recursive_path='/storage/external_SD/test-media/'
curl --silent -X POST \
--data-binary "@${image_path}" \
"http://${airplay_ip}/photo"
curl --silent "$image_url" | \
curl --silent -X POST \
--data-binary @- \
"http://${airplay_ip}/photo"
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${video_url_1}\nStart-Position: 0" \
"http://${airplay_ip}/play"
30 seconds
within currently playing video:
curl --silent -X GET \
"http://${airplay_ip}/scrub?position=30.0"
curl --silent -X GET \
"http://${airplay_ip}/rate?value=0.0"
curl --silent -X GET \
"http://${airplay_ip}/rate?value=1.0"
curl --silent -X GET \
"http://${airplay_ip}/rate?value=10.0"
curl --silent -X GET \
"http://${airplay_ip}/stop"
extended APIs:
30 seconds
forward relative to current position within currently playing video (30 second = 30*1e3 milliseconds):
curl --silent -X GET \
"http://${airplay_ip}/add-scrub-offset?value=30000"
30 seconds
backward relative to current position within currently playing video (30 second = 30*1e3 milliseconds):
curl --silent -X GET \
"http://${airplay_ip}/add-scrub-offset?value=-30000"
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${video_url_1}\nCaption-Location: ${caption_url_1}\nReferer: ${videos_page}\nStart-Position: 0" \
"http://${airplay_ip}/play"
# note: position < 1 is a percent of the total track length
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${video_url_2}\nCaption-Location: ${caption_url_2}\nReferer: ${videos_page}\nStart-Position: 0.5" \
"http://${airplay_ip}/queue"
# note: position >= 1 is a fixed offset (in seconds)
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${video_url_3}\nCaption-Location: ${caption_url_3}\nReferer: ${videos_page}\nStart-Position: 30" \
"http://${airplay_ip}/queue"
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${video_url_1}\nContent-Location: ${video_url_2}\nContent-Location: ${video_url_3}\nReferer: ${videos_page}" \
"http://${airplay_ip}/play"
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${drm_video_url_1}\nDRM-License-Scheme: ${drm_video_url_1_license_scheme}\nDRM-License-Server: ${drm_video_url_1_license_server}\nStart-Position: 10\nStop-Position: 30" \
"http://${airplay_ip}/play"
# note: position < 1 is a percent of the total track length
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${drm_video_url_2}\nDRM-License-Scheme: ${drm_video_url_2_license_scheme}\nDRM-License-Server: ${drm_video_url_2_license_server}\nStart-Position: 0.5" \
"http://${airplay_ip}/queue"
curl --silent -X GET \
"http://${airplay_ip}/next"
curl --silent -X GET \
"http://${airplay_ip}/previous"
curl --silent -X GET \
"http://${airplay_ip}/volume?value=0.0"
curl --silent -X GET \
"http://${airplay_ip}/volume?value=0.5"
curl --silent -X GET \
"http://${airplay_ip}/volume?value=1.0"
# note: audio amplification requires Android 4.4+
curl --silent -X GET \
"http://${airplay_ip}/volume?value=11.5"
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Caption-Location: ${caption_url_1}" \
"http://${airplay_ip}/load-captions"
curl --silent -X GET \
"http://${airplay_ip}/show-captions?toggle=0"
curl --silent -X GET \
"http://${airplay_ip}/show-captions?toggle=1"
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Apply-Embedded: false\nFont-Size: 20" \
"http://${airplay_ip}/set-captions-style"
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Apply-Embedded: true\nFont-Size: 0" \
"http://${airplay_ip}/set-captions-style"
curl --silent -X GET \
"http://${airplay_ip}/set-captions-offset?value=1000000"
curl --silent -X GET \
"http://${airplay_ip}/add-captions-offset?value=60000000"
curl --silent -X GET \
"http://${airplay_ip}/set-captions-offset?value=0"
# note: supported values: [off,one,all]. default: all
curl --silent -X GET \
"http://${airplay_ip}/repeat-mode?value=all"
# note: supported values: [fit,width,height,fill,zoom]. default: fit
curl --silent -X GET \
"http://${airplay_ip}/resize-mode?value=fit"
# note: position < 1 is a percent of the total track length
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${audio_flac_url}\nReferer: ${audio_flac_nfo}\nStart-Position: 0.5" \
"http://${airplay_ip}/play"
# note: position >= 1 is a fixed offset (in seconds)
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${audio_mp3s_m3u}\nReferer: ${audio_m3u_page}\nStart-Position: 30" \
"http://${airplay_ip}/play"
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${audio_mp3s_htm}\nReferer: ${audio_htm_page}\nStart-Position: 0" \
"http://${airplay_ip}/queue"
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${video_path}\nCaption-Location: ${subtt_path}\nStart-Position: 0" \
"http://${airplay_ip}/play"
# note: position < 1 is a percent of the total track length
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${audio_path}\nStart-Position: 0.5" \
"http://${airplay_ip}/queue"
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${plist_path}" \
"http://${airplay_ip}/play"
# note: IF the specified directory contains one or more media files, THEN does not recursively search for media files in subdirectories
# IF the specified directory does not contain any media files, THEN does recursively search for media files in all subdirectories
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${video_dir_path}" \
"http://${airplay_ip}/play"
# note: IF the specified directory contains one or more media files, THEN does not recursively search for media files in subdirectories
# IF the specified directory does not contain any media files, THEN does recursively search for media files in all subdirectories
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${audio_dir_path}" \
"http://${airplay_ip}/queue"
# note: IF the specified directory contains one or more media files, THEN does not recursively search for media files in subdirectories
# IF the specified directory does not contain any media files, THEN does recursively search for media files in all subdirectories
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "Content-Location: ${recursive_path}" \
"http://${airplay_ip}/play"
curl --silent -X GET \
"http://${airplay_ip}/show-player"
# note: PiP mode is only available on Android TV 7.0 or Android 8.0 and higher
curl --silent -X GET \
"http://${airplay_ip}/show-player-pip"
# note: audio playback will continue in the background
curl --silent -X GET \
"http://${airplay_ip}/hide-player"
curl --silent -X POST \
-H "Content-Type: text/plain" \
--data-binary "Lorem Ipsum" \
"http://${airplay_ip}/show-toast"
start an Activity with custom Intent attributes:
post_body='
package:
class:
action: android.intent.action.VIEW
data: http://example.com/video.m3u8
type: application/x-mpegurl
category: android.intent.category.DEFAULT
category: android.intent.category.BROWSABLE
flag: 0x10000000
flag: 0x00008000
extra-referUrl: http://example.com/videos.html
extra-textUrl: http://example.com/video.srt
extra-useCache: true
extra-startPos:
extra-stopPos:
extra-drmScheme: widevine
extra-drmUrl: http://widevine.example.com/
extra-reqHeader: Referer: http://example.com/videos.html
extra-reqHeader: Origin: http://example.com
extra-reqHeader: X-Requested-With: XMLHttpRequest
extra-reqHeader: User-Agent: Chrome/90
extra-drmHeader: Authorization: Bearer xxxxx
extra-drmHeader: Cookie: token=xxxxx; sessionID=yyyyy
chooser-title: Open HLS video stream in:
'
curl --silent -X POST \
-H "Content-Type: text/parameters" \
--data-binary "${post_body}" \
"http://${airplay_ip}/start-activity"
# note: also closes the video player foreground Activity, and kills the process
curl --silent -X GET \
"http://${airplay_ip}/exit-service"
/play
and /queue
API endpoints:
:
or =
characters, with optional whitespace/load-captions
API endpoint:
:
or =
characters, with optional whitespace/set-captions-style
API endpoint:
:
or =
characters, with optional whitespace0
is special and used to revert to default/start-activity
API endpoint:
:
or =
characters, with optional whitespaceString[]
(String)
(String[])
(bool)
(bool[])
(boolean)
(boolean[])
(byte)
(byte[])
(char)
(char[])
(double)
(double[])
(float)
(float[])
(int)
(int[])
(integer)
(integer[])
(long)
(long[])
(short)
(short[])
String[]
[value]
char[]
/(true|false)/i
boolean
/[+-]?\d+/
int
/[+-]?\d+L/i
long
/[+-]?\d+(\.\d+)?F/i
float
/[+-]?\d+(\.\d+)?D/i
double
String
/share-video
API endpoint:
:
or =
characters, with optional whitespacereferUrl: Referer
reqHeader: android.media.intent.extra.HTTP_HEADERS
/edit-preferences
API endpoint:
:
or =
characters, with optional whitespaceMozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4710.39 Safari/537.36
50
6
5000
(ie: 5 seconds)15000
(ie: 15 seconds)0.05
(ie: 5%)0.50
2.50
false
false
false
false
/show-toast
API endpoint:
single-page application (SPA) that can run in any web browser, and be used to:
WebCast-Reloaded Chrome extension that can run in any Chromium-based desktop web browser, and be used to:
WebCast Android app that is open-source, and can be used to:
Toaster Cast Android app that can be used to:
WebTorrent Desktop app (for Windows, Mac, Linux) that is open-source, and can be used to:
DroidPlay Android app that is open-source, and can be used to:
fork of: DroidPlay Android app that is open-source, and can additionally be used to:
airplay desktop app (for Java JRE) that is open-source, and can be used to:
airplay_ip='192.168.1.100:8192'
java -jar "airplay.jar" -h "$airplay_ip" -d
exoairtube desktop app (for Node.js) that is open-source, and can be used to:
HTTP Shortcuts Android app that is open-source, and can be used to:
Bookmarks Android app that is open-source, and can be used to:
CBS News
android.intent.action.VIEW
com.github.warren_bank.exoplayer_airplay_receiver
com.github.warren_bank.exoplayer_airplay_receiver.ui.StartNetworkingServiceActivity
https://www.cbsnews.com/common/video/cbsn_header_prod.m3u8
application/x-mpegurl
referUrl
String
https://www.cbsnews.com/live/
CBS News
doesn't actually require a Referer
HTTP request header, but one is added to illustrate how to do sostop
android.intent.action.MEDIA_BUTTON
com.github.warren_bank.exoplayer_airplay_receiver
androidx.media.session.MediaButtonReceiver
android.intent.extra.KEY_EVENT
int
86