From f02106726938bda1a603490e75419777561f89ab Mon Sep 17 00:00:00 2001 From: "Warren R. Bank" Date: Fri, 13 Mar 2020 07:18:38 -0700 Subject: [PATCH] add the ability to pre-process audio playlists * this is actually a really good feature * it's also designed in a way that's very easy to extend, which would enable pre-processing more URL patterns - for each URL pattern interceptor: * asked whether it should apply to a requested URL * if so, it implements a line parser that extracts URLs from text * currently, two URL pattern interceptors are implemented - .m3u audio playlists - .html files * this includes URLs that imply a directory index will be returned from the web server in .html format * current implementation only parses audio files from tags - includes logic to dedupe duplicate audio files * when the same audio file is available in multiple formats, applies a ranking of format preference and only returns one URL for the audio file in the most favorable format notes: ====== * this feature had previously been implemented in the javascript SPA - depended upon a CORS proxy to bypass browser security rules to be able to download the playlist URL (ex: m3u, html) * now this feature is available to all clients automagically, and there are no cross-origin concerns * I left the javascript implementation in the SPA for reference sake, but all relevent code has been commented out --- README.md | 41 +++++- .../service/NetworkingService.java | 124 +++++++++++++++--- .../BasePlaylistExtractor.java | 91 +++++++++++++ .../HtmlPlaylistExtractor.java | 98 ++++++++++++++ .../M3uPlaylistExtractor.java | 49 +++++++ .../utils/StringUtils.java | 35 +++++ android-studio-project/constants.gradle | 4 +- tests/01.sh | 94 ++++++++++++- tests/02. AirPlay sender.html | 29 +++- ...y sender - to receiver on same device.html | 29 +++- 10 files changed, 570 insertions(+), 24 deletions(-) create mode 100644 android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/BasePlaylistExtractor.java create mode 100644 android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/HtmlPlaylistExtractor.java create mode 100644 android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/M3uPlaylistExtractor.java diff --git a/README.md b/README.md index b4c4b22..cd9e8b3 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,9 @@ Android app to run on a set-top box and play video URLs "cast" to it with a stat - 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 + * to add support for: + - video queue, next, previous, mute, set volume + - audio playlists (_m3u_, _html_ directory index) #### Design: @@ -62,7 +64,7 @@ __AirPlay APIs:__ 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 videos: + # 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' @@ -73,6 +75,14 @@ __AirPlay APIs:__ caption_url_1='https://d12zt1n3pd4xhr.cloudfront.net/fp/subtitles-demo.vtt' caption_url_2='https://d12zt1n3pd4xhr.cloudfront.net/fp/subtitles-demo.vtt' caption_url_3='https://d12zt1n3pd4xhr.cloudfront.net/fp/subtitles-demo.vtt' + + # 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/' ``` * display image from local file system: @@ -125,7 +135,7 @@ __extended APIs:__ * add video #2 to end of queue (add text captions, set 'Referer' request header, seek to 50%): ```bash - # note: position < 1 is a percent of the total video length + # 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" \ @@ -189,6 +199,31 @@ __extended APIs:__ curl --silent -X POST -d "" \ "http://${airplay_ip}/set-captions-offset?value=0" ``` +* play audio .flac file (set 'Referer' request header, seek to 50%): + ```bash + # 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" + ``` +* play audio .m3u playlist (6 songs, set 'Referer' request header for all songs, seek to 30 seconds in first song): + ```bash + # note: position >= 1 is a fixed offset (in seconds) + # note: after the first song, each additional song is added following a 1 second delay; adding 6 songs will take 5 seconds to complete. + 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" + ``` +* add audio .html directory index playlist to end of queue (20 songs, set 'Referer' request header for all songs, seek to beginning of first song): + ```bash + # note: after the first song, each additional song is added following a 1 second delay; adding 20 songs will take 19 seconds to complete. + 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" + ``` #### Usage (high level): 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 index 36149e4..732be61 100644 --- 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 @@ -3,6 +3,7 @@ import java.io.IOException; import java.lang.ref.WeakReference; import java.net.InetAddress; +import java.util.ArrayList; import java.util.HashMap; import java.util.Locale; @@ -17,7 +18,9 @@ import android.net.wifi.WifiManager; import android.os.Build; import android.os.Handler; +import android.os.HandlerThread; import android.os.IBinder; +import android.os.Looper; import android.os.Message; import android.util.Log; import android.view.Gravity; @@ -28,6 +31,8 @@ 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.service.playlist_extractors.HtmlPlaylistExtractor; +import com.github.warren_bank.exoplayer_airplay_receiver.service.playlist_extractors.M3uPlaylistExtractor; 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; @@ -65,7 +70,11 @@ public void onCreate() { lock.setReferenceCounted(true); lock.acquire(); - handler = new ServiceHandler(NetworkingService.this); + // run ServiceHandler in a separate Thread to avoid NetworkOnMainThreadException + HandlerThread handlerThread = new HandlerThread("ServiceHandlerThread"); + handlerThread.start(); + + handler = new ServiceHandler(handlerThread.getLooper(), NetworkingService.this); MainApp.registerHandler(NetworkingService.class.getName(), handler); Toast toast = android.widget.Toast.makeText(getApplicationContext(), "Registering Airplay service...", android.widget.Toast.LENGTH_SHORT); @@ -333,10 +342,43 @@ private void processIntent(Intent intent) { // message handler private static class ServiceHandler extends Handler { + + private class DelayedVideoPlayerIntentRunnable implements Runnable { + private NetworkingService service; + private String uri; + private String referer; + + public DelayedVideoPlayerIntentRunnable(NetworkingService service, String uri, String referer) { + this.service = service; + this.uri = uri; + this.referer = referer; + } + + @Override + public void run() { + sendVideoPlayerIntent( + service, + /* mode= */ "queue", + /* uri= */ uri, + /* caption= */ "", + /* referer= */ referer, + /* startPosition= */ 0d + ); + } + } + private WeakReference weakReference; - public ServiceHandler(NetworkingService service) { + private M3uPlaylistExtractor m3uExtractor; + private HtmlPlaylistExtractor htmlExtractor; + + public ServiceHandler(Looper looper, NetworkingService service) { + super(looper); + weakReference = new WeakReference(service); + + m3uExtractor = new M3uPlaylistExtractor(); + htmlExtractor = new HtmlPlaylistExtractor(); } @Override @@ -361,11 +403,10 @@ public void handleMessage(Message msg) { 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); + sendImageViewerIntent( + service, + /* pic= */ (byte[]) msg.obj + ); break; } case Constant.Msg.Msg_Video_Play : @@ -376,18 +417,71 @@ public void handleMessage(Message msg) { String referUrl = map.get(Constant.RefererURL); String startPos = map.get(Constant.Start_Pos); - Intent intent = new Intent(service, VideoPlayerActivity.class); - intent.putExtra("mode", ((msg.what == Constant.Msg.Msg_Video_Play) ? "play" : "queue")); - intent.putExtra("uri", playUrl); - intent.putExtra("caption", textUrl); - intent.putExtra("referer", referUrl); - intent.putExtra("startPosition", Double.valueOf(startPos)); - intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - service.startActivity(intent); + ArrayList matches = null; + + if (matches == null) + matches = m3uExtractor.expandPlaylist(playUrl); //8-bit ascii + + if (matches == null) + matches = htmlExtractor.expandPlaylist(playUrl, (String) null); //utf8 + + if (matches != null) { + for (int counter = 0; counter < matches.size(); counter++) { + if (counter == 0) { + sendVideoPlayerIntent( + service, + /* mode= */ ((msg.what == Constant.Msg.Msg_Video_Play) ? "play" : "queue"), + /* uri= */ matches.get(counter), + /* caption= */ textUrl, + /* referer= */ referUrl, + /* startPosition= */ Double.valueOf(startPos) + ); + } + else { + ServiceHandler.this.postDelayed( + new DelayedVideoPlayerIntentRunnable( + service, + /* uri= */ matches.get(counter), + /* referer= */ referUrl + ), + (1000l * counter) + ); + } + } + break; + } + + sendVideoPlayerIntent( + service, + /* mode= */ ((msg.what == Constant.Msg.Msg_Video_Play) ? "play" : "queue"), + /* uri= */ playUrl, + /* caption= */ textUrl, + /* referer= */ referUrl, + /* startPosition= */ Double.valueOf(startPos) + ); break; } } } + + private void sendVideoPlayerIntent(NetworkingService service, String mode, String uri, String caption, String referer, double startPosition) { + Intent intent = new Intent(service, VideoPlayerActivity.class); + intent.putExtra("mode", mode); + intent.putExtra("uri", uri); + intent.putExtra("caption", caption); + intent.putExtra("referer", referer); + intent.putExtra("startPosition", startPosition); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + service.startActivity(intent); + } + + private void sendImageViewerIntent(NetworkingService service, byte[] pic) { + Intent intent = new Intent(service, ImageActivity.class); + intent.putExtra("picture", pic); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + service.startActivity(intent); + } + } } diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/BasePlaylistExtractor.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/BasePlaylistExtractor.java new file mode 100644 index 0000000..750a57c --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/BasePlaylistExtractor.java @@ -0,0 +1,91 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.service.playlist_extractors; + +import com.github.warren_bank.exoplayer_airplay_receiver.utils.StringUtils; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.URL; +import java.nio.charset.Charset; +import java.util.ArrayList; + +public abstract class BasePlaylistExtractor { + + protected abstract boolean isParserForUrl(String strUrl); + + protected abstract void parseLine(String line, URL context, ArrayList matches); + + protected void preParse(URL context) {} + + protected void postParse(URL context, ArrayList matches) {} + + public ArrayList expandPlaylist(String strUrl) { + // https://developer.android.com/reference/java/nio/charset/Charset#standard-charsets + // https://en.wikipedia.org/wiki/Extended_ASCII#ISO_8859_and_proprietary_adaptations + // https://en.wikipedia.org/wiki/ISO/IEC_8859-1 + return expandPlaylist(strUrl, "ISO-8859-1"); + } + + public ArrayList expandPlaylist(String strUrl, String charsetName) { + Charset cs = null; + + if ((charsetName == null) || charsetName.isEmpty()) { + cs = Charset.defaultCharset(); // UTF-8 + } + else { + try { + cs = Charset.forName(charsetName); + } + catch (Exception e) { + cs = Charset.defaultCharset(); // UTF-8 + } + } + + return expandPlaylist(strUrl, cs); + } + + protected ArrayList expandPlaylist(String strUrl, Charset cs) { + if (!isParserForUrl(strUrl)) + return null; + + ArrayList matches = new ArrayList(); + BufferedReader in = null; + + try { + URL url; + String line; + + // ascii encoded + url = new URL(strUrl); + + // Read all the text returned by the server + in = new BufferedReader(new InputStreamReader(url.openStream(), cs)); + + // remove ascii encoding + url = new URL(StringUtils.decodeURL(strUrl)); + + preParse(url); + while ((line = in.readLine()) != null) { + // `line` is one line of text; readLine() strips the newline character(s) + parseLine(line, url, matches); + } + postParse(url, matches); + } + catch (Exception e) { + } + finally { + if (in != null) { + try { + in.close(); + } + catch(Exception e) {} + } + + // normalize that non-null return value must include matches + if (matches.isEmpty()) + matches = null; + } + + return matches; + } + +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/HtmlPlaylistExtractor.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/HtmlPlaylistExtractor.java new file mode 100644 index 0000000..1528c44 --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/HtmlPlaylistExtractor.java @@ -0,0 +1,98 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.service.playlist_extractors; + +import com.github.warren_bank.exoplayer_airplay_receiver.utils.StringUtils; + +import java.net.URL; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +public class HtmlPlaylistExtractor extends BasePlaylistExtractor { + + private static Pattern playlist_regex = Pattern.compile("(?:\\/|\\.s?html?)(?:[\\?#]|$)"); + private static Pattern linkhref_regex = Pattern.compile("href=\"([^\"]+\\.)(mp3|m4a|ogg|wav|flac)\"", Pattern.CASE_INSENSITIVE); + private static String format_priority = "|mp3|m4a|ogg|wav|flac|"; + + private static int getFormatPriority(String format) { + return HtmlPlaylistExtractor.format_priority.indexOf(format); + } + + private HashMap url_chunks; + + @Override + protected void preParse(URL context) { + url_chunks = new HashMap(); + } + + @Override + protected void postParse(URL context, ArrayList matches) { + String href; + URL url; + + for (String[] val : url_chunks.values()) { + href = val[0] + val[1]; + + try { + // if `href` contains a relative spec, then resolve it relative to context + // if `href` contains an absolute spec, then context is ignored + url = new URL(context, href); + + matches.add( + StringUtils.encodeURL(url) + ); + } + catch(Exception e) {} + } + + url_chunks.clear(); + url_chunks = null; + } + + protected boolean isParserForUrl(String strUrl) { + if (strUrl == null) return false; + + Matcher matcher = HtmlPlaylistExtractor.playlist_regex.matcher(strUrl.toLowerCase()); + return matcher.find(); + } + + protected void parseLine(String line, URL context, ArrayList matches) { + if (line == null) return; + line = line.trim(); + + // remove empty lines + if (line.isEmpty()) return; + + Matcher matcher = HtmlPlaylistExtractor.linkhref_regex.matcher(line); + String m1, m2, lm1, lm2, lo2; + int po, pm; + String[] old; + + // find all matches in `line` + while (matcher.find()) { + m1 = matcher.group(1); + m2 = matcher.group(2); + + // remove ascii encoding + m1 = StringUtils.decodeURL(m1); + + lm1 = m1.toLowerCase(); + lm2 = m2.toLowerCase(); + + if (url_chunks.containsKey(lm1)) { + old = url_chunks.get(lm1); + lo2 = old[1].toLowerCase(); + + po = HtmlPlaylistExtractor.getFormatPriority(lo2); + pm = HtmlPlaylistExtractor.getFormatPriority(lm2); + + if ((pm >=0) && (pm < po)) + url_chunks.put(lm1, new String[] {m1,m2}); + } + else { + url_chunks.put(lm1, new String[] {m1,m2}); + } + } + } + +} diff --git a/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/M3uPlaylistExtractor.java b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/M3uPlaylistExtractor.java new file mode 100644 index 0000000..cd521ea --- /dev/null +++ b/android-studio-project/ExoPlayer-AirPlay-Receiver/src/main/java/com/github/warren_bank/exoplayer_airplay_receiver/service/playlist_extractors/M3uPlaylistExtractor.java @@ -0,0 +1,49 @@ +package com.github.warren_bank.exoplayer_airplay_receiver.service.playlist_extractors; + +import com.github.warren_bank.exoplayer_airplay_receiver.utils.StringUtils; + +import java.net.URL; +import java.util.ArrayList; +import java.util.regex.Pattern; +import java.util.regex.Matcher; + +public class M3uPlaylistExtractor extends BasePlaylistExtractor { + + private static Pattern playlist_regex = Pattern.compile("\\.(?:m3u)(?:[\\?#]|$)"); + + protected boolean isParserForUrl(String strUrl) { + if (strUrl == null) return false; + + Matcher matcher = M3uPlaylistExtractor.playlist_regex.matcher(strUrl.toLowerCase()); + return matcher.find(); + } + + protected void parseLine(String line, URL context, ArrayList matches) { + if (line == null) return; + line = line.trim(); + + // remove empty lines + if (line.isEmpty()) return; + + // remove comments + if (line.charAt(0) == '#') return; + + // remove ascii encoding + line = StringUtils.decodeURL(line); + + // not sure if this is a good idea.. convert Windows path separators + line = line.replaceAll("[\\\\]", "/"); + + try { + // if `line` contains a relative spec, then resolve it relative to context + // if `line` contains an absolute spec, then context is ignored + URL url = new URL(context, line); + + matches.add( + StringUtils.encodeURL(url) + ); + } + catch(Exception e) {} + } + +} 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 index 6d6c6e2..9dd9e16 100644 --- 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 @@ -1,5 +1,9 @@ package com.github.warren_bank.exoplayer_airplay_receiver.utils; +import java.net.URI; +import java.net.URL; +import java.net.URLDecoder; + public class StringUtils { public static String getValue(String textBlock, String prefix, String suffix) { @@ -41,4 +45,35 @@ public static String convertEscapedLinefeeds(String requestBody) { return requestBody.replaceAll("\\\\n", "\n"); } + public static String decodeURL(String strUrl) { + try { + return URLDecoder.decode(strUrl, "UTF-8"); + } + catch(Exception e) { + return strUrl; + } + } + + public static String encodeURL(String strUrl) { + try { + URL url = new URL(strUrl); + + return StringUtils.encodeURL(url); + } + catch(Exception e) { + return strUrl; + } + } + + public static String encodeURL(URL url) { + try { + URI uri = new URI(url.getProtocol(), url.getUserInfo(), url.getHost(), url.getPort(), url.getPath(), url.getQuery(), url.getRef()); + + return uri.toASCIIString(); + } + catch(Exception e) { + return url.toExternalForm(); + } + } + } diff --git a/android-studio-project/constants.gradle b/android-studio-project/constants.gradle index d241305..45a530f 100644 --- a/android-studio-project/constants.gradle +++ b/android-studio-project/constants.gradle @@ -1,6 +1,6 @@ project.ext { - releaseVersionCode = Integer.parseInt("001001016", 10) - releaseVersion = '001.00.10-16API' + releaseVersionCode = Integer.parseInt("001001116", 10) + releaseVersion = '001.00.11-16API' minSdkVersion = 16 targetSdkVersion = 28 compileSdkVersion = 28 diff --git a/tests/01.sh b/tests/01.sh index fa9f977..f9f73bb 100755 --- a/tests/01.sh +++ b/tests/01.sh @@ -6,7 +6,7 @@ airplay_ip='192.168.1.100:8192' # URL for test image: image_url='https://upload.wikimedia.org/wikipedia/commons/thumb/d/d7/Android_robot.svg/654px-Android_robot.svg.png' -# URLs for test videos: +# 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' @@ -17,6 +17,14 @@ caption_url_1='https://d12zt1n3pd4xhr.cloudfront.net/fp/subtitles-demo.vtt' caption_url_2='https://d12zt1n3pd4xhr.cloudfront.net/fp/subtitles-demo.vtt' caption_url_3='https://d12zt1n3pd4xhr.cloudfront.net/fp/subtitles-demo.vtt' +# 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/' + # display image from remote URL curl --silent "$image_url" | \ curl --silent -X POST \ @@ -56,7 +64,7 @@ curl --silent -X POST -d "" \ "http://${airplay_ip}/rate?value=10.0" # add video #2 to end of queue (add text captions, set 'Referer' request header, seek to 50%) -# note: position < 1 is a percent of the total video length +# 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" \ @@ -148,3 +156,85 @@ sleep 10 # stop playback curl --silent -X POST -d "" \ "http://${airplay_ip}/stop" + +sleep 5 + +# play audio .flac file (set 'Referer' request header, seek to 50%) +# 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" + +sleep 30 + +# play audio .m3u playlist (6 songs, set 'Referer' request header for all songs, seek to 30 seconds in first song) +# note: position >= 1 is a fixed offset (in seconds) +# note: after the first song, each additional song is added following a 1 second delay; adding 6 songs will take 5 seconds to complete. +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" + +sleep 10 + +# add audio .html directory index playlist to end of queue (20 songs, set 'Referer' request header for all songs, seek to beginning of first song) +# note: after the first song, each additional song is added following a 1 second delay; adding 20 songs will take 19 seconds to complete. +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" + +sleep 30 + +# skip forward to next song in queued playlist (.m3u song #2) +curl --silent -X POST -d "" \ + "http://${airplay_ip}/next" + +sleep 15 + +# skip forward to next song in queued playlist (.m3u song #3) +curl --silent -X POST -d "" \ + "http://${airplay_ip}/next" + +sleep 15 + +# skip forward to next song in queued playlist (.m3u song #4) +curl --silent -X POST -d "" \ + "http://${airplay_ip}/next" + +sleep 1 + +# skip forward to next song in queued playlist (.m3u song #5) +curl --silent -X POST -d "" \ + "http://${airplay_ip}/next" + +sleep 1 + +# skip forward to next song in queued playlist (.m3u song #6) +curl --silent -X POST -d "" \ + "http://${airplay_ip}/next" + +sleep 1 + +# skip forward to next song in queued playlist (.html song #1) +curl --silent -X POST -d "" \ + "http://${airplay_ip}/next" + +sleep 15 + +# skip forward to next song in queued playlist (.html song #2) +curl --silent -X POST -d "" \ + "http://${airplay_ip}/next" + +sleep 15 + +# skip forward to next song in queued playlist (.html song #3) +curl --silent -X POST -d "" \ + "http://${airplay_ip}/next" + +sleep 15 + +# stop playback +curl --silent -X POST -d "" \ + "http://${airplay_ip}/stop" diff --git a/tests/02. AirPlay sender.html b/tests/02. AirPlay sender.html index c37f2b3..dca88ec 100644 --- a/tests/02. AirPlay sender.html +++ b/tests/02. AirPlay sender.html @@ -320,6 +320,13 @@ // =============================================================================================== // form field data collection helper +/* ================================================================================================= + * the following functionality has been entirely re-implemented directly in the ExoAirPlay receiver. + * no special tooling is needed for the sender to "cast" audio playlists in the formats: + * - .m3u (audio/x-mpegurl) + * - .html (text/html) + * ================================================================================================= + const get_fetch_response = async (url) => { let response, proxied_url @@ -478,6 +485,9 @@ return urls } + * ================================================================================================= + */ + const get_video_POST_data = (url) => { if (!url) url = $video_url.value @@ -502,6 +512,13 @@ // =============================================================================================== // form field submission helper +/* ================================================================================================= + * the following functionality has been entirely re-implemented directly in the ExoAirPlay receiver. + * no special tooling is needed for the sender to "cast" audio playlists in the formats: + * - .m3u (audio/x-mpegurl) + * - .html (text/html) + * ================================================================================================= + const request_delay = (delay_ms) => { return new Promise((resolve) => { setTimeout(resolve, delay_ms) @@ -523,7 +540,7 @@ if (Array.isArray(urls) && urls.length) { for (let i = 0; i < urls.length; i++) { path = (i === 0) ? initial_path : '/queue' - data = get_video_POST_data(/* url= */ urls[i]) + data = get_video_POST_data(urls[i]) POST_message(path, data) @@ -538,6 +555,16 @@ } } + * ================================================================================================= + */ + + const send_video_POST_data = async (initial_path) => { + path = initial_path + data = get_video_POST_data() + + POST_message(path, data) + } + // =============================================================================================== // add event listeners to form fields diff --git a/tests/03. AirPlay sender - to receiver on same device.html b/tests/03. AirPlay sender - to receiver on same device.html index b3b522c..13dfe6d 100644 --- a/tests/03. AirPlay sender - to receiver on same device.html +++ b/tests/03. AirPlay sender - to receiver on same device.html @@ -119,6 +119,13 @@ // =============================================================================================== // form field data collection helper +/* ================================================================================================= + * the following functionality has been entirely re-implemented directly in the ExoAirPlay receiver. + * no special tooling is needed for the sender to "cast" audio playlists in the formats: + * - .m3u (audio/x-mpegurl) + * - .html (text/html) + * ================================================================================================= + const get_fetch_response = async (url) => { let response, proxied_url @@ -273,6 +280,9 @@ return urls } + * ================================================================================================= + */ + const get_video_POST_data = (url) => { if (!url) url = $video_url.value @@ -297,6 +307,13 @@ // =============================================================================================== // form field submission helper +/* ================================================================================================= + * the following functionality has been entirely re-implemented directly in the ExoAirPlay receiver. + * no special tooling is needed for the sender to "cast" audio playlists in the formats: + * - .m3u (audio/x-mpegurl) + * - .html (text/html) + * ================================================================================================= + const request_delay = (delay_ms) => { return new Promise((resolve) => { setTimeout(resolve, delay_ms) @@ -318,7 +335,7 @@ if (Array.isArray(urls) && urls.length) { for (let i = 0; i < urls.length; i++) { path = (i === 0) ? initial_path : '/queue' - data = get_video_POST_data(/* url= */ urls[i]) + data = get_video_POST_data(urls[i]) POST_message(path, data) @@ -333,6 +350,16 @@ } } + * ================================================================================================= + */ + + const send_video_POST_data = async (initial_path) => { + path = initial_path + data = get_video_POST_data() + + POST_message(path, data) + } + // =============================================================================================== // add event listeners to form fields