この記事では、Android端末内に保存されているファイルをChromeCastで再生する方法を説明します
はじめに
ChromeCastでメディアを再生したい場合、CastするメディアのURLが必要です。
そのため、端末のローカルファイルをChromeCastで再生する場合には、ChromeCastから参照できる場所にファイルを公開しなければなりません。
この記事ではアプリ自身でHttpServerを立てることで、ChromeCastが端末のファイルにアクセスできるようにします!
処理の流れは下記のようになっています。
- ローカルの音楽ファイルを取得する
- サーバを起動し、ファイルを返却する
- ファイルをChromeCastから再生する
1. ローカルの音楽ファイルを取得する
ContentResolver
を使用して端末内(SDカード含む)の音楽ファイルを取得します。
アルバムアートの取得が少し複雑です。
参考:java - Most robust way to fetch album art in Android - Stack Overflow
private List<MediaMetadataCompat> fetchMedia() {
List<MediaMetadataCompat> mediaList = new ArrayList<>();
Cursor mediaCursor = getContentResolver().query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
new String[]{
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DATA,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.ALBUM_ID},
MediaStore.Audio.Media.IS_MUSIC + " != 0", null, null);
if (mediaCursor != null && mediaCursor.moveToFirst()) {
do {
mediaList.add(buildMediaMetadataCompat(mediaCursor));
} while (mediaCursor.moveToNext());
mediaCursor.close();
}
return mediaList;
}
private MediaMetadataCompat buildMediaMetadataCompat(Cursor cursor) {
return new MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, cursor.getString(0))
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, cursor.getString(1))
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, cursor.getString(2))
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, cursor.getString(3))
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, cursor.getString(4))
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,
getAlbumArtUri(cursor.getLong(5)).toString())
.build();
}
private Uri getAlbumArtUri(long albumId) {
Uri albumArtUri = Uri.parse("content://media/external/audio/albumart");
return ContentUris.withAppendedId(albumArtUri, albumId);
}
SDカードの中を探すのでManifest.permission.READ_EXTERNAL_STORAGE
が必要です。
private void checkPermissions() {
if (Build.VERSION.SDK_INT >= 23) {
ActivityCompat.requestPermissions(this, new String[]{
Manifest.permission.READ_EXTERNAL_STORAGE}, 1);
}
}
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
2. サーバを起動し、ファイルを返却する
HttpServerにNanoHttpdを使用します。
dependencies {
compile 'com.nanohttpd:nanohttpd-webserver:2.1.1'
}
ポイントは3つです!
-
WifiManager
を使用して端末のIPアドレスを調べる - 空いているポートを探す
- サーバを起動する
private void startSever() {
try {
String ip = getWifiAddress();
int port = findOpenPort(ip, 8080);
mHttpServer = new HttpServer(port);
mHttpServer.start();
} catch (IOException e) {
e.printStackTrace();
}
}
private String getWifiAddress() {
WifiManager wifiManager = (WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
WifiInfo wifiInfo = wifiManager.getConnectionInfo();
int ipAddress = wifiInfo.getIpAddress();
return ((ipAddress) & 0xFF) + "." +
((ipAddress >> 8) & 0xFF) + "." +
((ipAddress >> 16) & 0xFF) + "." +
((ipAddress >> 24) & 0xFF);
}
private int findOpenPort(String ip, int startPort) {
final int timeout = 200;
for (int port = startPort; port <= 65535; port++) {
if (isPortAvailable(ip, port, timeout)) {
return port;
}
}
throw new RuntimeException("There is no open port.");
}
private boolean isPortAvailable(String ip, int port, int timeout) {
try {
Socket socket = new Socket();
socket.connect(new InetSocketAddress(ip, port), timeout);
socket.close();
return false;
} catch (Exception e) {
return true;
}
}
wifiの情報を取得するので、Manifest.permission.ACCESS_WIFI_STATE
が必要です
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
NanoHTTPD
は音楽とアルバムアートをそれぞれ返却する必要があります。
Uriに応じてレスポンスを分岐させます。
private class HttpServer extends NanoHTTPD {
final int mPort;
MediaMetadataCompat mMedia;
HttpServer(int port) throws IOException {
super(port);
mPort = port;
}
void setMedia(MediaMetadataCompat media) {
mMedia = media;
}
@Override
public Response serve(IHTTPSession session) {
if (mMedia == null) {
return new Response(NOT_FOUND, MIME_PLAINTEXT, "No music");
}
if (session.getUri().contains("image")) {
return serveImage();
} else {
return serveMusic();
}
}
private Response serveMusic() {
InputStream stream = null;
try {
stream = new FileInputStream(
mMedia.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI));
} catch (FileNotFoundException e) {
e.printStackTrace();
}
return new Response(OK, "audio/mp3", stream);
}
private Response serveImage() {
InputStream stream = null;
try {
stream = getContentResolver().openInputStream(Uri.parse(
mMedia.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI)));
} catch (FileNotFoundException e) {
e.printStackTrace();
}
return new Response(OK, "image/jpeg", stream);
}
}
3. ファイルをChromeCastから再生する
play-services-cast-framework
を使用します
dependencies {
compile 'com.google.android.gms:play-services-cast-framework:10.0.1'
}
ネットワーク上にChromeCastが存在する場合のみmenuにアイコンを表示します。
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
getMenuInflater().inflate(R.menu.main, menu);
try {
CastButtonFactory.setUpMediaRouteButton(getApplicationContext(),
menu, R.id.media_route_menu_item);
} catch (RuntimeException e) {
e.printStackTrace();
}
return true;
}
再生ボタンが押されたら、ローカルファイルからランダムに1曲再生することにします。
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item != null && item.getItemId() == R.id.play) {
List<MediaMetadataCompat> mediaList = fetchMedia();
if (mediaList == null || mediaList.isEmpty()) {
return true;
}
Collections.shuffle(mediaList);
if (mHttpServer == null || !mHttpServer.isAlive()) {
startSever();
}
String url = "http://" + getWifiAddress() + ":" + mHttpServer.mPort;
mHttpServer.setMedia(mediaList.get(0));
castMedia(url, mediaList.get(0));
return true;
}
return super.onOptionsItemSelected(item);
}
ChromeCastに音楽を転送します。
private void castMedia(String url, MediaMetadataCompat track) {
CastSession castSession = CastContext.getSharedInstance(getApplicationContext()).getSessionManager()
.getCurrentCastSession();
if (castSession != null) {
MediaInfo media = toCastMediaMetadata(url, track);
RemoteMediaClient remoteMediaClient = castSession.getRemoteMediaClient();
if (remoteMediaClient != null) {
remoteMediaClient.load(media);
}
}
}
private MediaInfo toCastMediaMetadata(String url, MediaMetadataCompat track) {
MediaMetadata mediaMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK);
mediaMetadata.putString(MediaMetadata.KEY_TITLE,
track.getDescription().getTitle() == null ? "" :
track.getDescription().getTitle().toString());
mediaMetadata.putString(MediaMetadata.KEY_SUBTITLE,
track.getDescription().getSubtitle() == null ? "" :
track.getDescription().getSubtitle().toString());
mediaMetadata.putString(MediaMetadata.KEY_ALBUM_ARTIST,
track.getString(MediaMetadataCompat.METADATA_KEY_ARTIST));
mediaMetadata.putString(MediaMetadata.KEY_ALBUM_TITLE,
track.getString(MediaMetadataCompat.METADATA_KEY_ALBUM));
WebImage image = new WebImage(
new Uri.Builder().encodedPath(url + "/image").build());
// First image is used by the receiver for showing the audio album art.
mediaMetadata.addImage(image);
// Second image is used by Cast Companion Library on the full screen activity that is shown
// when the cast dialog is clicked.
mediaMetadata.addImage(image);
return new MediaInfo.Builder(url)
.setContentType("audio/mpeg")
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(mediaMetadata)
.build();
}
CastContext
を用いてCastを実装しているのでOptionsProvider
を定義する必要があります。
<meta-data
android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
android:value="com.sjn.demo.localfilecast.CastOptionsProvider"/>
package com.sjn.demo.localfilecast;
import android.content.Context;
import com.google.android.gms.cast.CastMediaControlIntent;
import com.google.android.gms.cast.framework.CastOptions;
import com.google.android.gms.cast.framework.OptionsProvider;
import com.google.android.gms.cast.framework.SessionProvider;
import java.util.List;
public class CastOptionsProvider implements OptionsProvider {
@Override
public CastOptions getCastOptions(Context context) {
return new CastOptions.Builder()
.setReceiverApplicationId(CastMediaControlIntent.DEFAULT_MEDIA_RECEIVER_APPLICATION_ID)
.build();
}
@Override
public List<SessionProvider> getAdditionalSessionProviders(Context context) {
return null;
}
}
最終的にMainActivity.java
はこのようになります
package com.sjn.demo.localfilecast;
import android.Manifest;
import android.content.ContentUris;
import android.database.Cursor;
import android.net.Uri;
import android.net.wifi.WifiInfo;
import android.net.wifi.WifiManager;
import android.os.Build;
import android.os.Bundle;
import android.provider.MediaStore;
import android.support.v4.app.ActivityCompat;
import android.support.v4.media.MediaMetadataCompat;
import android.support.v7.app.AppCompatActivity;
import android.view.Menu;
import android.view.MenuItem;
import com.google.android.gms.cast.MediaInfo;
import com.google.android.gms.cast.MediaMetadata;
import com.google.android.gms.cast.framework.CastButtonFactory;
import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.cast.framework.CastSession;
import com.google.android.gms.cast.framework.media.RemoteMediaClient;
import com.google.android.gms.common.images.WebImage;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import fi.iki.elonen.NanoHTTPD;
import static fi.iki.elonen.NanoHTTPD.Response.Status.NOT_FOUND;
import static fi.iki.elonen.NanoHTTPD.Response.Status.OK;
public class MainActivity extends AppCompatActivity {
private HttpServer mHttpServer;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
checkPermissions();
}
@Override
protected void onDestroy() {
super.onDestroy();
if (mHttpServer != null && mHttpServer.isAlive()) {
mHttpServer.stop();
}
}
@Override
public boolean onCreateOptionsMenu(Menu menu) {
super.onCreateOptionsMenu(menu);
getMenuInflater().inflate(R.menu.main, menu);
try {
CastButtonFactory.setUpMediaRouteButton(getApplicationContext(),
menu, R.id.media_route_menu_item);
} catch (RuntimeException e) {
e.printStackTrace();
}
return true;
}
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item != null && item.getItemId() == R.id.play) {
List<MediaMetadataCompat> mediaList = fetchMedia();
if (mediaList == null || mediaList.isEmpty()) {
return true;
}
Collections.shuffle(mediaList);
if (mHttpServer == null || !mHttpServer.isAlive()) {
startSever();
}
String url = "http://" + getWifiAddress() + ":" + mHttpServer.mPort;
mHttpServer.setMedia(mediaList.get(0));
castMedia(url, mediaList.get(0));
return true;
}
return super.onOptionsItemSelected(item);
}
private class HttpServer extends NanoHTTPD {
final int mPort;
MediaMetadataCompat mMedia;
HttpServer(int port) throws IOException {
super(port);
mPort = port;
}
void setMedia(MediaMetadataCompat media) {
mMedia = media;
}
@Override
public Response serve(IHTTPSession session) {
if (mMedia == null) {
return new Response(NOT_FOUND, MIME_PLAINTEXT, "No music");
}
if (session.getUri().contains("image")) {
return serveImage();
} else {
return serveMusic();
}
}
private Response serveMusic() {
InputStream stream = null;
try {
stream = new FileInputStream(
mMedia.getString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI));
} catch (FileNotFoundException e) {
e.printStackTrace();
}
return new Response(OK, "audio/mp3", stream);
}
private Response serveImage() {
InputStream stream = null;
try {
stream = getContentResolver().openInputStream(Uri.parse(
mMedia.getString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI)));
} catch (FileNotFoundException e) {
e.printStackTrace();
}
return new Response(OK, "image/jpeg", stream);
}
}
private void startSever() {
try {
String ip = getWifiAddress();
int port = findOpenPort(ip, 8080);
mHttpServer = new HttpServer(port);
mHttpServer.start();
} catch (IOException e) {
e.printStackTrace();
}
}
private String getWifiAddress() {
WifiManager wifiManager = (WifiManager) getApplicationContext().getSystemService(WIFI_SERVICE);
WifiInfo wifiInfo = wifiManager.getConnectionInfo();
int ipAddress = wifiInfo.getIpAddress();
return ((ipAddress) & 0xFF) + "." +
((ipAddress >> 8) & 0xFF) + "." +
((ipAddress >> 16) & 0xFF) + "." +
((ipAddress >> 24) & 0xFF);
}
private int findOpenPort(String ip, int startPort) {
final int timeout = 200;
for (int port = startPort; port <= 65535; port++) {
if (isPortAvailable(ip, port, timeout)) {
return port;
}
}
throw new RuntimeException("There is no open port.");
}
private boolean isPortAvailable(String ip, int port, int timeout) {
try {
Socket socket = new Socket();
socket.connect(new InetSocketAddress(ip, port), timeout);
socket.close();
return false;
} catch (Exception e) {
return true;
}
}
private void checkPermissions() {
if (Build.VERSION.SDK_INT >= 23) {
ActivityCompat.requestPermissions(this, new String[]{
Manifest.permission.READ_EXTERNAL_STORAGE}, 1);
}
}
private List<MediaMetadataCompat> fetchMedia() {
List<MediaMetadataCompat> mediaList = new ArrayList<>();
Cursor mediaCursor = getContentResolver().query(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
new String[]{
MediaStore.Audio.Media._ID,
MediaStore.Audio.Media.DATA,
MediaStore.Audio.Media.TITLE,
MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.ALBUM_ID},
MediaStore.Audio.Media.IS_MUSIC + " != 0", null, null);
if (mediaCursor != null && mediaCursor.moveToFirst()) {
do {
mediaList.add(buildMediaMetadataCompat(mediaCursor));
} while (mediaCursor.moveToNext());
mediaCursor.close();
}
return mediaList;
}
private MediaMetadataCompat buildMediaMetadataCompat(Cursor cursor) {
return new MediaMetadataCompat.Builder()
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_ID, cursor.getString(0))
.putString(MediaMetadataCompat.METADATA_KEY_MEDIA_URI, cursor.getString(1))
.putString(MediaMetadataCompat.METADATA_KEY_TITLE, cursor.getString(2))
.putString(MediaMetadataCompat.METADATA_KEY_ARTIST, cursor.getString(3))
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM, cursor.getString(4))
.putString(MediaMetadataCompat.METADATA_KEY_ALBUM_ART_URI,
getAlbumArtUri(cursor.getLong(5)).toString())
.build();
}
private Uri getAlbumArtUri(long albumId) {
Uri albumArtUri = Uri.parse("content://media/external/audio/albumart");
return ContentUris.withAppendedId(albumArtUri, albumId);
}
void castMedia(String url, MediaMetadataCompat track) {
CastSession castSession = CastContext.getSharedInstance(getApplicationContext()).getSessionManager()
.getCurrentCastSession();
if (castSession != null) {
MediaInfo media = toCastMediaMetadata(url, track);
RemoteMediaClient remoteMediaClient = castSession.getRemoteMediaClient();
if (remoteMediaClient != null) {
remoteMediaClient.load(media);
}
}
}
private MediaInfo toCastMediaMetadata(String url, MediaMetadataCompat track) {
MediaMetadata mediaMetadata = new MediaMetadata(MediaMetadata.MEDIA_TYPE_MUSIC_TRACK);
mediaMetadata.putString(MediaMetadata.KEY_TITLE,
track.getDescription().getTitle() == null ? "" :
track.getDescription().getTitle().toString());
mediaMetadata.putString(MediaMetadata.KEY_SUBTITLE,
track.getDescription().getSubtitle() == null ? "" :
track.getDescription().getSubtitle().toString());
mediaMetadata.putString(MediaMetadata.KEY_ALBUM_ARTIST,
track.getString(MediaMetadataCompat.METADATA_KEY_ARTIST));
mediaMetadata.putString(MediaMetadata.KEY_ALBUM_TITLE,
track.getString(MediaMetadataCompat.METADATA_KEY_ALBUM));
WebImage image = new WebImage(
new Uri.Builder().encodedPath(url + "/image").build());
// First image is used by the receiver for showing the audio album art.
mediaMetadata.addImage(image);
// Second image is used by Cast Companion Library on the full screen activity that is shown
// when the cast dialog is clicked.
mediaMetadata.addImage(image);
return new MediaInfo.Builder(url)
.setContentType("audio/mpeg")
.setStreamType(MediaInfo.STREAM_TYPE_BUFFERED)
.setMetadata(mediaMetadata)
.build();
}
}
おわりに
NanoHTTPD
がとても使いやすかったです。アプリの機能としてだけでなく、開発中のデバッグ情報をプライベートネットワーク上に公開するような使い方もしてみたいと思いました。
今回のソースはsjnyag/localfilecast-demoで公開しています