Spiele-Apps für Chromecast – Teil 2: Game Manager API

In dieser Serie möchte ich zusammenfassen, wie man Spiele für Googles Chromecast baut. Der Chromecast ist eine verdammt günstige Variante, um auf einem großen Fernseher Inhalte vom Smartphone darzustellen und eben auch darauf Spiele zu spielen. Google Cast, also die passende API, ist auch in Android TV oder anderen kompatiblen Geräten verfügbar. Spiele mit Chromecast sind momentan noch sehr rar. Die meisten Apps zielen auf Streaming von Multimediainhalten ab, wie Videos, Fotos oder Musik. Wir wollen das jetzt ändern. Ziel der Serie soll ein Spiel sein, welches man mit mehreren Mitspielern gleichzeitig auf einem TV-Gerät spielen kann, wobei das Smartphone als Eingabegerät gilt.

Teil 2 soll euch eine Einführung in die Game Manager API geben, die von Google auf der I/O 2015 im Sommer vorgestellt wurde und die Entwicklung von Apps drastisch vereinfachen sollen. Ein paar Kerngebiete der Game Manager API sind:

  • Kommunikation zwischen den Spielern
  • Synchronisation der Spielstände zwischen den Sendern
  • Beitreten und Verlassen durch weitere Spieler

Vorbereitung

Ihr solltet euch bereits mit der Entwicklung von Chromecast-Apps vertraut gemacht haben. Wenn nicht, könnt ihr euch zunächst Teil 1: Hallo Welt durchlesen. Zum Testen der Multiplayer-Funktionen, ist es hilfreich ein zweites Android-Gerät zu besitzen, mit dem Ihr ebenfalls dem Spiel beitreten könnt.

Das Tutorial verwendet als Grundlage den Source Code aus Teil 1: Hallo Welt und erweitert ihn um die Game Manager API.

Game Manager API vs. Remote Display API

Die Game Manager API grenzt sich von der Remote Display API dahingehend ab, dass das Spielgeschehen im Receiver implementiert ist und somit auch die gesamte Logik dort stattfindet. Remote Display Apps hingegen erweitern die Darstellung um einen zweiten Bildschirm, wobei der Inhalt dessen vollständig auf dem Smartphone gerendert wird und lediglich auf dem Chromecast angezeigt wird. Das Display des Smartphones kann in beiden Fällen dann für Steuerelemente genutzt werden.

Die Remote Display API darf aber nicht mit der Screen Mirroring-Funktion verwechselt werden. Technologien wie Miracast oder Screen Cast spiegeln den Smartphoneinhalt auf das Anzeigegerät. Wenn das Display des Smartphones aus ist, so ist auch der Inhalt des Anzeigegeräts in der Regel schwarz. Ein anderer Inhalt auf den jeweiligen Displays geht auch nicht.

Sender aufräumen

Das Besondere an der Game Manager API ist, dass er sich auch um die Channels kümmert, über die die Kommunikation abläuft. Wir brauchen uns also keine Gedanken um irgendwelche Namespaces machen. Wir können also einiges aus unserem Source Code entfernen:

  • die Eigenschaft mMyChannel
  • die Klasse MyChannel
  • die Methoden registerChannel(), unregisterChannel() und sendMessage()
  • alle Funktionsaufrufe der vorher genannten Methoden
  • ungenutzte Imports

GameManagerClient im Sender initialisieren

Der GameManagerClient erledigt sämtliche Arbeit für uns. Er ist relativ einfach zu initialisieren:

    ...
    private GameManagerClient mGameManagerClient;

    ...

    private class CastResultCallback implements ResultCallback<Cast.ApplicationConnectionResult> {
        @Override
        public void onResult(Cast.ApplicationConnectionResult applicationConnectionResult) {
            Status status = applicationConnectionResult.getStatus();
            if (status.isSuccess()) {
                ...

                mSessionId = applicationConnectionResult.getSessionId();
                
                ...

                GameManagerClient.getInstanceFor(mApiClient, mSessionId)
                        .setResultCallback(new GameManagerClientResultCallback());
            } else {
                teardown();
            }
        }
    }

    ...

    private class GameManagerClientResultCallback
            implements ResultCallback<GameManagerClient.GameManagerInstanceResult> {
        @Override
        public void onResult(
                GameManagerClient.GameManagerInstanceResult gameManagerInstanceResult) {
            mGameManagerClient = gameManagerInstanceResult.getGameManagerClient();
        }
    }

    ...

    private void teardown() {
        ...
                if (mApiClient.isConnected() || mApiClient.isConnecting()) {
                    try {
                        Cast.CastApi.stopApplication(mApiClient, mSessionId);
                        mGameManagerClient.dispose();
                    } catch (Exception e) {
                        Log.e(TAG, "Failed to stop application", e);
                    }
                    mApiClient.disconnect();
                }
        ...
    }

Damit sind wir auch schon komplett und könnten mit den Funktionen des GameManagerClients die Spiellogik umsetzen. Fehlt noch der Receiver.

GameManager im Receiver initialisieren

Auf der Receiver-Seite ist der GameManager die wichtigste Klasse, die wir für unser Spiel benötigen. Auch hier bereinigen wir unseren Code von den alten Sachen, die wir nicht mehr brauchen:

  • die Variable namespace
  • die window.messageBus und window.messageBus.onMessage-Objekte
  • die onMessage()-Funktion

Im Anschluss initialisieren wir den GameManager :

...
<head>
  <script src="//www.gstatic.com/cast/sdk/libs/receiver/2.0.0/cast_receiver.js"></script>
  <script src="//www.gstatic.com/cast/sdk/libs/games/1.0.0/cast_games_receiver.js"></script>
  <style type="text/css">
...

  <script>

    var gameManager;
  
    window.onload = function() {
      cast.receiver.logger.setLevelValue(0);
      window.castReceiverManager = cast.receiver.CastReceiverManager.getInstance();       

      var gameConfig = new cast.receiver.games.GameManagerConfig();
      gameConfig.applicationName = 'My Game';
      gameConfig.maxPlayers = 8;
      gameManager = new cast.receiver.games.GameManager(gameConfig);

      // Starte den Receiver.
      window.castReceiverManager.start({statusText: "Application is starting"});
      console.log('Receiver Manager started');
    };

    ...

  </script>

Status in der App ausgeben

Wir sind mit dem Einbinden der GameManager-API damit schon fertig, da die weiteren Schritte nun konkret auf die Implementierung der Spiellogik eingehen werden. Stattdessen wollen wir zunächst lediglich anzeigen lassen, dass der GameManager tatsächlich schon funktioniert. Das Textlabel in der content_main.xml passen wir so an, dass wir es im Code wiederfinden:

<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    ...
    tools:showIn="@layout/activity_main" tools:context=".MainActivity">

    <TextView
        android:id="@+id/status"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content" />
</RelativeLayout>

Dann erstellen wir eine Funktion, die den Status des Spiels ermittelt und im Textlabel ausgibt:

    ...

    private void onGameConnected() {
        TextView tv = (TextView) findViewById(R.id.status);

        GameManagerState state = mGameManagerClient.getCurrentState();

        String status = state.getApplicationName() + "\n" +
                "Status: " + state.getGameStatusText() + "\n" +
                "Gameplay state: " + state.getGameplayState() + "\n";

        tv.setText(status);
    }

    ...

und rufen diese auf, wenn der GameManagerClient gesetzt wurde:

    ...

    private class GameManagerClientResultCallback
            implements ResultCallback<GameManagerClient.GameManagerInstanceResult> {
        @Override
        public void onResult(
                GameManagerClient.GameManagerInstanceResult gameManagerInstanceResult) {
            mGameManagerClient = gameManagerInstanceResult.getGameManagerClient();
            onGameConnected();
        }
    }

    ...

Wenn wir die App nun starten, sollten wir folgendes Ergebnis sehen:

Screenshot_20151110-220411

Zusammenfassung

Das Einbinden des GameManagers ist im Vergleich zur Einbindung des Chromecast-SDKs schon fast ein Kinderspiel. Die weiteren Schritte sind nun, sich eine sinnvolle Spiellogik auszudenken und über die GameManager des Senders und Receivers abzudecken. Dies soll in den weiteren Folgen betrachtet werden.

Die MainActivity ist inzwischen so stark angewachsen und unübersichtlich mit all deren Listener und Callback-Klassen, dass ich diese auslagern werde. Dies wird in den Samples von Google ebenfalls gemacht. Der Vorteil besteht darin, dass man in der MainActivity dann nur noch den tatsächlich spielrelevanten Code hat und die Schnittstellenthematik in einer separaten Klasse behandelt wird.

Die Sourcen des gesamten Tutorials, wie es hier beschrieben ist, könnt ihr euch hier herunterladen: chromecast_game_tutorial.zip. Die Quellen und dieses Tutorial sind unter der Apache 2.0 Lizenz entwickelt worden.

Referenzen

Für die Ausarbeitung dieses Tutorials habe ich folgende Quellen verwendet:

Google Cast Game Manager API

weitere Quellen sind wie immer die Samples von Google auf GitHub.

Leider wird es dann schon recht rar mit guten Quellen für die Entwicklung von Spieleapps mit Chromecast.

Spiele-Apps für Chromecast – Teil 1: Hallo Welt

In dieser Serie möchte ich zusammenfassen, wie man Spiele für Googles Chromecast baut. Der Chromecast ist eine verdammt günstige Variante, um auf einem großen Fernseher Inhalte vom Smartphone darzustellen und eben auch darauf Spiele zu spielen. Google Cast, also die passende API, ist auch in Android TV oder anderen kompatiblen Geräten verfügbar. Spiele mit Chromecast sind momentan noch sehr rar. Die meisten Apps zielen auf Streaming von Multimediainhalten ab, wie Videos, Fotos oder Musik. Wir wollen das jetzt ändern. Ziel der Serie soll ein Spiel sein, welches man mit mehreren Mitspielern gleichzeitig auf einem TV-Gerät spielen kann, wobei das Smartphone als Eingabegerät gilt.

Teil 1 der Serie beschäftigt sich mit der allgemeinen Einrichtung des Chromecast als Entwicklergerät und wie man eine erste Verbindung mit dem Chromecast herstellt. Der Beitrag ist Schritt für Schritt erläutert, was ihn zwar sehr lang, aber hoffentlich sehr leicht nachvollziehbar macht.

Vorbereitung

Wir benötigen für die Entwicklung (in Klammern steht meine Konfiguration):

  • Chromecast 1. Gen oder 2. Gen, kein Audio-Only (1. Gen)
  • Android Studio (1.4.1) mit allen notwendigen SDKs installiert
    • Android Support Library (rev. 23.1)
    • Google Play Services (rev. 27)
  • ein Android-Telefon mit Android 4.2 oder höher (Nexus 5, Marshmallow 6.0)
  • einen Webserver für den Cast-Receiver (kleine Webserver mit etwas Speicher gibt es reichlich z.T. auch kostenlos)
  • ein eingerichtetes Google-Wallet für die Cast Developer Console

Einrichten der Google Cast Developer Console

Zu Beginn müssen wir uns an der Google Cast Developer Console (https://cast.google.com/publish) anmelden. Im Gegensatz zu Android-Apps muss man beim Entwickeln von Cast-Apps bereits vor der Veröffentlichung der Anwendung eine Anmeldegebühr entrichten. Diese kostet zur Zeit US$5 und kann nur mit einem Google-Wallet-Account bezahlt werden. Hierfür ist unter Umständen eine Kreditkarte nötig. Wenn Ihr später die App in den Play-Store bringen wollt, benötigt ihr wohlmöglich ohnehin eine. Aber inzwischen gibt es auch Kreditkarten ohne Jahresgebühr.

Anmeldung bei der Google Cast Developer Console
Anmeldung bei der Google Cast Developer Console

Wenn Ihr den Registrierungsvorgang abgeschlossen habt, richten wir den Chromecast in der Geräteliste ein. Die Seriennummer steht hinten auf dem Stick oder der Originalverpackung. Die Option, die Seriennummer auf dem entsprechenden Gerät ansagen zu lassen, ist im Übrigen sehr nützlich, wenn man beides auf die schnelle nicht erreichen kann. Die Registrierung dauert wie angegeben etwa 15 bis 20 Minuten. Vergesst bitte danach nicht, den Stick neu zu starten.

Registrierung des Chromecast als Entwicklergerät
Registrierung des Chromecast als Entwicklergerät

Im Anschluss können wir uns eine App-ID generieren, in dem wir eine App anlegen. Es gibt vier verschiedene Cast Receiver:

  • DefaultMediaReceiver, der zum Streamen von Multimediadateien verwendet wird, aber keinerlei Anpassung der Oberfläche erlaubt. Daher muss er auch nicht explizit registriert werden, sondern ist bereits Teil des Chromcast OS.
  • StyledMediaReceiver, der zwar auch nur zum Streamen von Medien gedacht ist, aber im Gegensatz zum DefaultMediaReceiver sich auch in gewissen Maßen gestalten lässt.
  • RemoteDisplayReceiver, der im wesentlichen die auf dem Smartphone oder Tablet gerenderten Inhalte zum Chromcast sendet und dort zur Anzeige bringt. Dieser Receiver ist zwar auch für Spiele geeignet, aber der Sender ist hier aktiver Part und muss die Inhalte vorbereiten, die auf dem TV-Gerät angezeigt werden sollen. Somit können nicht mehrere Spieler gleichzeitig an einer Spielrunde teilnehmen bzw. nur mit Umwegen. Der RemoteDisplayReceiver ist zudem erst ab Android 4.4 verfügbar und bisher noch im Beta-Stadium.
  • CustomReceiver, der in allen Teilen angepasst werden kann, aber eben auch angepasst werden muss. Dieser Receiver kommt immer dann zum Einsatz, wenn die oben genannten Spezialreceiver nicht verwendet werden können.

Wir verwenden also den CustomReceiver und erstellen eine App. Der Name sollte dem späteren App-Namen in Android entsprechen, damit man eine bessere Zuordnung hat. Die URL zum Receiver ist hier bereits auszufüllen, obwohl wir den Receiver noch nicht erstellt haben, andernfalls erhalten wir keine App-ID. Wir können bisweilen eine Fake-Adresse angeben und später ändern, sobald wir den Receiver fertig für einen ersten Test haben.

Einen neuen Custom Receiver anlegen
Einen neuen Custom Receiver anlegen

Man kann in den Einstellungen noch festlegen, welche Regeln bei der späteren Veröffentlichung gelten sollen, also zum Beispiel in welchem Land die App verfügbar sein soll oder auf welchen Geräten sie unterstütz wird. Dem widmen wir uns aber später.

CustomReceiver

Alle Receiver sind Web-Apps geschrieben in HTML5, JavaScript und CCS3. Mit der Einführung von WebGL sind inzwischen auch grafikintensive Spiele denkbar, aber im Moment noch uninteressant für uns. Die Einbindung des Chromecast-SDKs für Receiver erfolgt über folgende URL:

//www.gstatic.com/cast/sdk/libs/receiver/2.0.0/cast_receiver.js

Wir bauen uns daraus folgendes HTML-Grundgerüst, welches prinzipiell für alle Chromecast-Apps verwendet werden könnte. Das wichtigste Objekt für die Kommunikation zwischen Sender (der Smartphone-App) und dem Receiver stellt der CastReceiverManager dar. Er stellt die Verbindung her und benachrichtigt uns beim Empfang von Daten. Diesen Kontext benötigen wir häufiger und erstellen uns daher eine globale Referenz auf die Instanz des Managers und starten ihn.

Außerdem müssen wir noch einen MessageBus mit unseren Namensraum erstellen, über den die Kommunikation laufen soll. Der Namensraum wird uns später erneut begegnen. Namensräume in Google Cast werden immer mit dem Präfix angegeben:

urn:x-cast:

Das ganze wird dann in der onLoad()  ausgeführt, wenn die Seite fertig geladen wurde.

<html>
<head>
  <script src="//www.gstatic.com/cast/sdk/libs/receiver/2.0.0/cast_receiver.js"></script>
</head>
<body>
  <script>
    // Hier folgt der Code für den Custom Receiver

    var namespace = 'urn:x-cast:com.example.test'

    window.onload = function() {
      window.castReceiverManager = cast.receiver.CastReceiverManager.getInstance();       

      // Erstelle MessageBus zum Austauschen von Daten
      window.messageBus = window.castReceiverManager.getCastMessageBus(namespace);

      // Lege onMessage-Callback für den MessageBus fest.
      window.messageBus.onMessage = onMessage;

      // Starte den Receiver.
      window.castReceiverManager.start({statusText: "Application is starting"});
      console.log('Receiver Manager started');
    };
    
    function onMessage(event) {
      window.messageBus.send(event.senderId, event.data);
    }

  </script>
</body>
</html>

Sender-App

Kommen wir nun zunächst zum Sender. Wir beschreiben eine Android-App, die zunächst nichts anderes tut, als den Receiver zu starten. Wir legen daher ein Android-Projekt mit Minimum SDK API Level 17 (Android 4.2 Jelly Bean) an und erzeugen eine leere Activity (BlankActivity). Anschließend erfüllen wir die Abhängigkeiten, die Google Cast verlangt und fügen in der build.gradle des App-Moduls die markierten Zeilen zu den Dependencies hinzu:

dependencies {
    compile fileTree(include: ['*.jar'], dir: 'libs')
    testCompile 'junit:junit:4.12'
    compile 'com.android.support:appcompat-v7:23.1.0'
    compile 'com.android.support:design:23.1.0'
    compile 'com.android.support:mediarouter-v7:23.1.0'
    compile 'com.google.android.gms:play-services:8.1.0'
}

Bevor wir mit der Arbeit an der App beginnen, legen wir uns zunächst eine Ressourcendatei für den API-Schlüssel an. Ich empfehle hier ein ähnliches Muster zu verwenden, wie es häufig bei Google Maps zum Einsatz kommt. Wir legen also im Ordner /res/values/ eine Datei namens google_cast_api.xml  mit folgendem Inhalt an:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <string name="google_cast_key" translatable="false" templateMergeStrategy="preserve">
        <!-- Dein Google-Cast-API-Schlüssel -->
    </string>
</resources>

Wichtig ist, dass Ihr in der markierten Zeile euren Schlüssel einsetzt, den ihr auf der Google Cast Developer Console erhalten habt.

Als nächstes binden wir den Chromecast-Button ein. Hierfür nutzen wir das Toolbar-Menü. Wir fügen im Hauptmenü menu_main.xml einen neuen ActionButton hinzu, der wie folgt aussieht:

<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools" tools:context=".MainActivity">

    <item
        android:id="@+id/media_route_menu_item"
        android:title="@string/media_route_menu_title"
        app:actionProviderClass="android.support.v7.app.MediaRouteActionProvider"
        app:showAsAction="always"/>

    <item android:id="@+id/action_settings" android:title="@string/action_settings"
        android:orderInCategory="100" app:showAsAction="never" />
</menu>

Den String für das Element fügen wir zur strings.xml hinzu:

<resources>
    <string name="app_name">DESORONA</string>
    <string name="action_settings">Settings</string>
    <string name="media_route_menu_title">Cast</string>
</resources>

In der MainActivity fügen wir folgende Zeilen hinzu:

public class MainActivity extends AppCompatActivity {

    private static final String TAG = MainActivity.class.getSimpleName();
    private MediaRouter mMediaRouter;
    private MediaRouteSelector mMediaRouteSelector;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        Toolbar toolbar = (Toolbar) findViewById(R.id.toolbar);
        setSupportActionBar(toolbar);

        ...

        mMediaRouter = MediaRouter.getInstance(getApplicationContext());
        mMediaRouteSelector = new MediaRouteSelector.Builder()
                .addControlCategory(
                        CastMediaControlIntent.categoryForCast(getString(R.string.google_cast_key)))
                .build();
    }
    
    ...

Hierüber initialisieren wir den MediaRouter, den wir über die gesamte Lebenszeit der Activity benötigen und speichern ihn daher als Eigenschaft. Der MediaRouteSelector wird für die Suche nach Cast-Geräten verwendet. Hier wird auch die Verknüpfung zu unserer App in der Cast Developer Console über den Key aus der eben angelegten Ressourcendatei hergestellt. Die Konstante TAG  verwenden wir zum Loggen im logcat.

Den Button in der ActionBar müssen wir nun noch sauber initialisieren und erweitern die onCreateOptionsMenu-Funktion:

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        // Inflate the menu; this adds items to the action bar if it is present.
        getMenuInflater().inflate(R.menu.menu_main, menu);

        MenuItem mediaRouteMenuItem = menu.findItem(R.id.media_route_menu_item);
        MediaRouteActionProvider mediaRouteActionProvider =
                (MediaRouteActionProvider) MenuItemCompat.getActionProvider(mediaRouteMenuItem);
        mediaRouteActionProvider.setRouteSelector(mMediaRouteSelector);

        return true;
    }

Einbinden des MediaRouter.Callback

In der App kann man leider noch immer keinen Cast-Button sehen. Der wird immer erst dann eingeblendet, wenn der Scan erfolgreich war. Hierfür müssen wir den MediaRouter.Callback einbinden.

    ...
    private MediaRouter mMediaRouter;
    private MediaRouteSelector mMediaRouteSelector;
    private CastDevice mSelectedDevice;
    private MediaRouterCallback mMediaRouterCallback = new MediaRouterCallback();

    private class MediaRouterCallback extends MediaRouter.Callback {

        @Override
        public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo info) {
            mSelectedDevice = CastDevice.getFromBundle(info.getExtras());
        }

        @Override
        public void onRouteUnselected(MediaRouter router, MediaRouter.RouteInfo info) {
            mSelectedDevice = null;
        }
    }

Jetzt überschreiben wir noch die onStart()  und onStop()  der MainActivity wie folgt:

    ...
    @Override
    protected void onStart() {
        super.onStart();
        mMediaRouter.addCallback(mMediaRouteSelector, mMediaRouterCallback,
                MediaRouter.CALLBACK_FLAG_REQUEST_DISCOVERY);
    }

    @Override
    protected void onStop() {
        mMediaRouter.removeCallback(mMediaRouterCallback);
        super.onStop();
    }
    ...

Und endlich können wir erste Erfolge verbuchen.

Screenshot_20151105-180133

Wenn bei euch der Button nicht angezeigt wird, vergewissert euch, dass Ihr den API-Schlüssel korrekt übernommen habt und der Packagepfad in der Cast Developer Console mit eurem Package-Pfad der App übereinstimmt. Auch muss der Chromecast korrekt eingerichtet worden sein. Prüft hier einfach noch einmal die Seriennummer.

Receiver starten

Man kann sich zwar nun mit seinem Chromecast verbinden, aber es wird noch immer kein Receiver angezeigt. Dieser muss nun noch gestartet werden, wofür weitere Callbacks notwendig sind. Wir erstellen zunächst leere Callbacks. Um deren Inhalte kümmern wir uns gleich.

    ...
    private ConnectionCallbacks mConnectionCallbacks = new ConnectionCallbacks();
    private ConnectionFailedListener mConnectionFailedListener = new ConnectionFailedListener();

    ...

    private class ConnectionCallbacks implements GoogleApiClient.ConnectionCallbacks {

        @Override
        public void onConnected(Bundle bundle) {

        }

        @Override
        public void onConnectionSuspended(int i) {

        }
    }

    private class ConnectionFailedListener implements GoogleApiClient.OnConnectionFailedListener {

        @Override
        public void onConnectionFailed(ConnectionResult connectionResult) {
            
        }
    }

Vorher erstellen wir uns eine neue Funktion launchReceiver() in der MainActivity, die den Receiver letztlich starten wird. Der Aufruf erstellt zunächst die API mit der der Google Play Service ausgeführt werden soll. Des Weiteren übergeben wir die Callbacks, die wir eben vorbereitet haben.

    ...
    private GoogleApiClient mApiClient;

    ...

    private void launchReceiver() {        
        Cast.CastOptions apiOptions =
                Cast.CastOptions.builder(mSelectedDevice, new Cast.Listener())
                .build();
        mApiClient = new GoogleApiClient.Builder(this)
                .addApi(Cast.API, apiOptions)
                .addConnectionCallbacks(mConnectionCallbacks)
                .addOnConnectionFailedListener(mConnectionFailedListener)
                .build();
        
        mApiClient.connect();
    }

    ...

Diese Funktion rufen wir nun im MediaRouterCallback auf:

    ...
    private class MediaRouterCallback extends MediaRouter.Callback {

        @Override
        public void onRouteSelected(MediaRouter router, MediaRouter.RouteInfo info) {
            mSelectedDevice = CastDevice.getFromBundle(info.getExtras());

            launchReceiver();
        }

    ...

Jetzt starten wir den Receiver auf dem Chromecast. Mit den LaunchOptions sagen wir, dass wir beim erneuten Verbinden, die vorherige Sitzung nicht automatisch neustarten wollen. Dies ist insbesondere dann nervig, wenn wir ein Multiplayer-Spiel spielen und der Host das Spiel verlässt und somit der Stand des Spiels verloren geht. Im ResultCallback legen wir fest, was getan werden soll, wenn der Aufruf erfolgreich war. Wir loggen erstmal nur den Erfolg.

    private ConnectionFailedListener mConnectionFailedListener = new ConnectionFailedListener();
    private CastResultCallback mCastResultCallback = new CastResultCallback();
    private GoogleApiClient mApiClient;

    ...

    private class ConnectionCallbacks implements GoogleApiClient.ConnectionCallbacks {

        @Override
        public void onConnected(Bundle bundle) {
            LaunchOptions options = new LaunchOptions.Builder()
                    .setRelaunchIfRunning(false)
                    .build();
            Cast.CastApi
                    .launchApplication(mApiClient, getString(R.string.google_cast_key), options)
                    .setResultCallback(mCastResultCallback);
        }
        ...
    }

    ...

    private class CastResultCallback implements ResultCallback<Cast.ApplicationConnectionResult> {
        @Override
        public void onResult(Cast.ApplicationConnectionResult applicationConnectionResult) {
            Log.d(TAG, "onResult");
        }
    }

Wir können jetzt erfolgreich ein Cast-Gerät wählen und unseren eigenen Receiver starten. Soweit so gut.

Receiver beenden

Es fehlt uns nun noch die Möglichkeit, die Verbindung wieder zu trennen, so dass wir zum Stoppen des Receivers entweder den Chromecast neustarten oder über eine andere App einen anderen Receiver starten müssten. Daher bauen wir zunächst noch die Möglichkeit ein, den Receiver auch in unserer App zu beenden. Die Art und Weise, wie eine Session bei Google Cast beendet werden muss, ist recht strickt und wir benötigen dazu noch ein paar weiterführende Informationen. Diese holen wir uns, sobald der Receiver gestartet wurde. Wir ersetzen also unsere Log-Zeile mit den markierten Zeilen unten.

    private boolean mApplicationStarted;
    private String mSessionId;
    
    ...
    
    private class CastResultCallback implements ResultCallback<Cast.ApplicationConnectionResult> {
        @Override
        public void onResult(Cast.ApplicationConnectionResult applicationConnectionResult) {
            Status status = applicationConnectionResult.getStatus();
            if (status.isSuccess()) {
                ApplicationMetadata applicationMetadata =
                        applicationConnectionResult.getApplicationMetadata();
                String applicationStatus =
                        applicationConnectionResult.getApplicationStatus();

                mSessionId = applicationConnectionResult.getSessionId();
                Log.d(TAG, "Application: " + applicationMetadata.getName() + " " +
                            "(SID: " + mSessionId +
                            ", status=" + applicationStatus + ")");
                mApplicationStarted = true;
            }
        }
    }

    ...

Mit deam Teardown beenden wir nun die Sitzung. Die Funktion schließt in genau umgekehrter Reihenfolge, wie wir die Sitzung ursprünglich aufgebaut haben. Dabei werden auch sämtliche Eigenschaften wieder zurückgesetzt, so dass wir am Ende wieder einen sauberen Stand haben.

    ...
    
    private void teardown() {
        Log.d(TAG, "Teardown Google cast");
        if (mApiClient != null) {
            if (mApplicationStarted) {
                if (mApiClient.isConnected() || mApiClient.isConnecting()) {
                    Cast.CastApi.stopApplication(mApiClient, mSessionId);
                    mApiClient.disconnect();
                }
                mApplicationStarted = false;
            }
            mApiClient = null;
        }
        mSelectedDevice = null;
        mSessionId = null;
    }

    ...

Diese Funktion rufen wir nun überall dort auf, wo wir entweder die Sitzung beenden wollen oder ein Fehler aufgetreten ist. Außerdem überschreiben wir noch die onDestroy()  der MainActivity , so dass beim Beenden der App ebenfalls die Verbindung getrennt wird.

    ...
    private class MediaRouterCallback extends MediaRouter.Callback {
        ...
        @Override
        public void onRouteUnselected(MediaRouter router, MediaRouter.RouteInfo info) {
            teardown();
            mSelectedDevice = null;
        }
    }

    ...

    private class ConnectionFailedListener implements GoogleApiClient.OnConnectionFailedListener {

        @Override
        public void onConnectionFailed(ConnectionResult connectionResult) {
            teardown();
        }
    }

    ...

    private class CastResultCallback implements ResultCallback<Cast.ApplicationConnectionResult> {
        @Override
        public void onResult(Cast.ApplicationConnectionResult applicationConnectionResult) {
            Status status = applicationConnectionResult.getStatus();
            if (status.isSuccess()) {
                ...
            } else {
                teardown();
            }
        }
    }

    @Override
    protected void onDestroy() {
        teardown();
        super.onDestroy();
    }

    ...

Der Cast.Listener  enthält zudem noch eine Methode, welche ebenfalls informiert wird, sobald die Verbindung zum Receiver beendet wurde. Dazu müssen wir die bisherige Standardimplementierung des Cast.Listeners  herausziehen und erweitern:

    ...
    private CastListener mCastListener = new CastListener();

    ...

    private class CastListener extends Cast.Listener {
        @Override
        public void onApplicationDisconnected(int statusCode) {
            teardown();
            super.onApplicationDisconnected(statusCode);
        }
    }

    ...

    private void launchReceiver() {
        Cast.CastOptions apiOptions =
                Cast.CastOptions.builder(mSelectedDevice, mCastListener)
                .setVerboseLoggingEnabled(true)
                .build();

        ...
    }

    ...

Es werden noch weitere Stellen folgen, bei denen ein Teardown zu erfolgen hat.

Abstürze vermeiden

Die App funktioniert inzwischen schon sehr gut, allerdings kann sie noch abstürzen, was leider daran liegt, dass die Google Play Services RuntimeExceptions verwenden und so auf dem ersten Blick nicht ersichtlich ist, dass hier eine Exception fliegen kann. Prinzipiell sollte man daher alle Play Service-API-Aufrufe in try-catch-Blöcke packen. Wir haben bereits drei dieser Stellen im Code, die wir jetzt absichern:

    ...
    private class ConnectionCallbacks implements GoogleApiClient.ConnectionCallbacks {

        @Override
        public void onConnected(Bundle bundle) {
            try {
                LaunchOptions options = new LaunchOptions.Builder()
                        .setRelaunchIfRunning(false)
                        .build();
                Cast.CastApi
                        .launchApplication(mApiClient, getString(R.string.google_cast_key), options)
                        .setResultCallback(mCastResultCallback);
            } catch (Exception e) {
                Log.e(TAG, "Failed to launch application", e);
            }
        }
        ...
    }

    ...

    private void launchReceiver() {
        try {
            Cast.CastOptions apiOptions =
                    Cast.CastOptions.builder(mSelectedDevice, mCastListener)
                            .setVerboseLoggingEnabled(true)
                            .build();

            mApiClient = new GoogleApiClient.Builder(this)
                    .addApi(Cast.API, apiOptions)
                    .addConnectionCallbacks(mConnectionCallbacks)
                    .addOnConnectionFailedListener(mConnectionFailedListener)
                    .build();

            mApiClient.connect();
        } catch (Exception e) {
            Log.e(TAG, "Failed to launch receiver.", e);
        }
    }

    ...

    private void teardown() {
        Log.d(TAG, "Teardown Google cast");
        if (mApiClient != null) {
            if (mApplicationStarted) {
                if (mApiClient.isConnected() || mApiClient.isConnecting()) {
                    try {
                        Cast.CastApi.stopApplication(mApiClient, mSessionId);
                    } catch (Exception e) {
                        Log.e(TAG, "Failed to stop application", e);
                    }
                    mApiClient.disconnect();
                }
                mApplicationStarted = false;
            }
            mApiClient = null;
        }
        mSelectedDevice = null;
        mSessionId = null;
    }

Als weitere stabilisierende Maßnahme, wenngleich es kein Absturz ist, stellt die Prüfung auf eine möglicherweise unterbrochene Verbindung dar. Wir prüfen außerdem, ob der ApiClient vorzeitig gelöscht wurde, da die Verbindung während der Ausführung bereits zurückgesetzt wurde- Dazu müssen erneut die ConnectionCallbacks angepasst werden:

    ...
    private class ConnectionCallbacks implements GoogleApiClient.ConnectionCallbacks {

        @Override
        public void onConnected(Bundle bundle) {

            if (mApiClient == null) {
                return;
            }

            try {
                if (mWaitingForReconnect) {
                    mWaitingForReconnect = false;

                    if (bundle != null && bundle.getBoolean(Cast.EXTRA_APP_NO_LONGER_RUNNING)) {
                        teardown();
                    }
                } else {
                    LaunchOptions options = new LaunchOptions.Builder()
                            .setRelaunchIfRunning(false)
                            .build();
                    Cast.CastApi
                            .launchApplication(mApiClient, getString(R.string.google_cast_key), options)
                            .setResultCallback(mCastResultCallback);
                }
            } catch (Exception e) {
                Log.e(TAG, "Failed to launch application", e);
            }
        }

        @Override
        public void onConnectionSuspended(int i) {
            mWaitingForReconnect = true;
        }
    }
    ...

Channel einbinden

Die App kann leider noch immer nicht direkt mit dem Receiver kommunizieren. Hierfür ist ein Channel notwendig, der die Nachrichten zum Receiver sendet und dort verarbeitet. Die Kommunikation kann in beide Richtungen stattfinden. Jeder Channel hat seinen eigenen Namensraum und muss zu dem des Receivers passen. Wir ergänzen daher unsere google_cast_api.xml um einen weiteren String:

<resources>
    ...
    <string name="my_channel_namespace" translatable="false" templateMergeStrategy="preserve">
        urn:x-cast:com.example.test
    </string>
</resources>

Wir erstellen uns jetzt zunächst einen MessageReceivedCallback für die Rückrichtung vom Receiver zur App:

    ...
    private MyChannel mMyChannel;

    ...

    private class MyChannel implements Cast.MessageReceivedCallback {

        public String getNamespace() {
            return getString(R.string.my_channel_namespace);
        }

        @Override
        public void onMessageReceived(CastDevice castDevice, String namespace,
                                      String message) {
            Log.d(TAG, "onMessageReceived: " + message);
        }

    }
    ...

Die Registrierung und Deregistrierung des Channels lagern wir in zwei Funktionen aus. Interessanterweise werfen diese beiden API-Calls Checked Exceptions, die wir explizit behandeln müssen.

    ...
    private void registerChannel() {
        try {
            mMyChannel = new MyChannel();
            Cast.CastApi.setMessageReceivedCallbacks(
                    mApiClient,
                    mMyChannel.getNamespace(),
                    mMyChannel);
        } catch (IOException e) {
            Log.e(TAG, "Exception while creating channel", e);
        }
    }

    private void unregisterChannel() {
        if (mMyChannel != null) {
            try {
                Cast.CastApi.removeMessageReceivedCallbacks(
                        mApiClient,
                        mMyChannel.getNamespace());
            } catch (IOException e) {
                Log.e(TAG, "Exception while removing channel", e);
            }
            mMyChannel = null;
        }
    }
    ...

Die Registrierung erfolgt an zwei Stellen. Einmal, nach dem die Verbindung erfolgreich hergestellt wurde im onResult des CastResultCallback :

                ...
                mSessionId = applicationConnectionResult.getSessionId();
                Log.d(TAG, "Application: " + applicationMetadata.getName() + " " +
                            "(SID: " + mSessionId +
                            ", status=" + applicationStatus + ")");
                mApplicationStarted = true;

                registerChannel();
            } else {
                teardown();
            }
            ...

und in der onConnected() der ConnectionCallbacks, wenn die Sitzung unterbrochen wurde, aber fortgesetzt werden soll:

                    ...
                    if (bundle != null && bundle.getBoolean(Cast.EXTRA_APP_NO_LONGER_RUNNING)) {
                        teardown();
                    } else {
                        registerChannel();
                    }
                } else {
                    ...

und die Deregistrierung im teardown(), wenn die Anwendung gestoppt wurde:

                    ...
                    try {
                        Cast.CastApi.stopApplication(mApiClient, mSessionId);
                        unregisterChannel();
                    } catch (Exception e) {
                        Log.e(TAG, "Failed to stop application", e);
                    }
                    ...

Fehlt noch eine Funktion zum Senden von Nachrichten:

    ...
    private void sendMessage(String message) {
        if (mApiClient != null && mMyChannel != null) {
            try {
                Cast.CastApi.sendMessage(mApiClient, mMyChannel.getNamespace(), message)
                        .setResultCallback(
                                new ResultCallback<Status>() {
                                    @Override
                                    public void onResult(Status result) {
                                        if (!result.isSuccess()) {
                                            Log.e(TAG, "Sending message failed");
                                        }
                                    }
                                });
            } catch (Exception e) {
                Log.e(TAG, "Exception while sending message", e);
            }
        } else {
            Log.w(TAG, "Not connected or channel invalid");
        }
    }
    ...

So lange wir noch kein fertiges UI-Konzept haben, knüpfen wir diese Funktion erst einmal an den Floating Action Button, der beim Anlegen der Activity mit erstellt wurde:

    ...
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        ...

        FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
        fab.setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View view) {
                sendMessage("A message from our sender");
                Snackbar.make(view, "Message sent", Snackbar.LENGTH_LONG)
                        .setAction("Action", null).show();
            }
        });
        ...

Empfang von Nachrichten im Receiver

Der Receiver muss nun auch noch erweitert werden, so dass wir Nachrichten am Receiver empfangen können. Dies ist jedoch denkbar einfach, da wir ja bereits den MessageBus vorbereitet haben. Wir bauen uns eine kleine Funktion, welche den gesendeten Text in einem Div-Container anzeigt:

...
<body>
  <div id="message">Hello World!</div>
  <script>

    ...

    function onMessage(event) {
      displayText(event.data);
      window.messageBus.send(event.senderId, event.data);
    }
    
    function displayText(text) {
      document.getElementById("message").innerHTML=text;
    }
  </script>
</body>
</html>

Wenn auf eurem Gerät nicht angezeigt wird, kann das daran liegen, dass der Text noch unformatiert ist und daher recht klein in der linken oberen Ecke klemmt. Am Besten erstellt ihr euch noch ein zusätzliches Stylesheet, welches den Div-Container in die Mitte des Anzeigebereichs rückt und gebt ihm eine kräftige Farbe.

Zusammenfassung

Wie man an der Länge des Beitrags erkennen kann, ist es eine ziemlich umfangreiche Arbeit, Apps mit Chromecast-Support zu bauen. Besonders wenn einem der Einstieg fehlt, sind die Tutorials im Netz leider sehr kompliziert und gehen von einem vollständigem Beispiel aus. Ich hoffe, euch mit diesem Tutorial jedoch jeden einzelnen Schritt bis zum Ziel erklärt und euch einen einfachen Einstieg in die Chromecast-App-Entwicklung gegeben zu haben.

Die Sourcen des gesamten Tutorials, wie es hier beschrieben ist, könnt ihr euch hier herunterladen: chromecast_tutorial.zip. Die Quellen und dieses Tutorial sind unter der Apache 2.0 Lizenz entwickelt worden.

Referenzen

Für die Ausarbeitung dieses Tutorials habe ich folgende Quellen verwendet:

HelloText-Android – Google Cast demo application: die Demo-App, an der ich mich orientiert habe.

Google Cast Developer Guides: die offiziellen Google Dokumentationen zur Cast API

Developing Chromecast Ready Application for Android Platform: ein ebenfalls sehr gut geschriebenes Tutorial auf Englisch.

Interessante weiterführende Links:

Cast Companion Library (CCL): eine Bibliothek, welche die Einbindung der Cast-API drastisch vereinfachen soll. Diese Bibliothek lege ich allen ans Herz, die primär mit eigenen Streaming-Cast-Apps arbeiten wollen.