Android内にHttpサーバを立てて、ChromeCastでローカルファイルを再生する

この記事では、Android端末内に保存されているファイルをChromeCastで再生する方法を説明します:grinning:

# はじめに
ChromeCastでメディアを再生したい場合、CastするメディアのURLが必要です。
そのため、端末のローカルファイルをChromeCastで再生する場合には、ChromeCastから参照できる場所にファイルを公開しなければなりません。
この記事では**アプリ自身でHttpServerを立てることで、ChromeCastが端末のファイルにアクセスできるようにします！**

処理の流れは下記のようになっています。
1. ローカルの音楽ファイルを取得する
2. サーバを起動し、ファイルを返却する
3. ファイルをChromeCastから再生する

# 1. ローカルの音楽ファイルを取得する
`ContentResolver`を使用して端末内（SDカード含む）の音楽ファイルを取得します。
アルバムアートの取得が少し複雑です。
参考：[java - Most robust way to fetch album art in Android - Stack Overflow](http://stackoverflow.com/questions/6591087/most-robust-way-to-fetch-album-art-in-android)

```java:MainActivity.java
    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`が必要です。

```java:MainActivity.java
    private void checkPermissions() {
        if (Build.VERSION.SDK_INT >= 23) {
            ActivityCompat.requestPermissions(this, new String[]{
                    Manifest.permission.READ_EXTERNAL_STORAGE}, 1);
        }
    }
```

```xml:AndroidManifest.xml
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
```

# 2. サーバを起動し、ファイルを返却する
HttpServerに[NanoHttpd](https://github.com/NanoHttpd/nanohttpd)を使用します。

```build.gradle
dependencies {
    compile 'com.nanohttpd:nanohttpd-webserver:2.1.1'
}
```

ポイントは3つです！
1. `WifiManager`を使用して端末のIPアドレスを調べる
2. 空いているポートを探す
3. サーバを起動する

```java:MainActivity.java
    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`が必要です

```xml:AndroidManifest.xml
    <uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
```

`NanoHTTPD`は音楽とアルバムアートをそれぞれ返却する必要があります。
Uriに応じてレスポンスを分岐させます。

```java:MainActivity.java
    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`を使用します

```java:build.gradle
dependencies {
    compile 'com.google.android.gms:play-services-cast-framework:10.0.1'
}
```

ネットワーク上にChromeCastが存在する場合のみmenuにアイコンを表示します。

```java:MainActivity.java
    @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;
    }
```

再生ボタンが押されたら、ローカルファイルからランダムに１曲再生することにします。

```java:MainActivity.java
    @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に音楽を転送します。

```java:MainActivity.java
    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`を定義する必要があります。

```xml:AndroidManifest.xml
        <meta-data
            android:name="com.google.android.gms.cast.framework.OPTIONS_PROVIDER_CLASS_NAME"
            android:value="com.sjn.demo.localfilecast.CastOptionsProvider"/>
```

```java:CastOptionProvider.java
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`はこのようになります:disappointed_relieved:

```Java: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](https://github.com/sjnyag/localfilecast-demo)で公開しています:smiley: