Loupe

Diffuser un contenu audio sur Chrome Cast ou Google Home depuis une application Android

Les Google Home et ChromeCast sont maintenant des matériels bien connus permettant de lire des contenus audio pilotés par un appareil mobile. Dans cet article nous verrons comment diffuser du contenu Audio depuis une application Android réalisé avec Xamarin. Thomas nous avait aussi déjà montré comment créer des actions personnalisées.

2018-06-18-image-20.jpg

Fonctionnement théorique

L'idée de base du fonctionnement est très simple : le ChromeCast ou GoogleHome sert de diffuseur de contenu et un périphérique, votre téléphone dans la plupart des cas, sert de manette de contrôle. 

L'application sur le Cast est appelée "Receiver" est peut être :

  • Une application "générique" : le Default Receiver App.
  • Une application "générique" que l'on peut personnaliser à l'aide de CSS.
  • Une application complètement customisée. 

Dans notre cas, nous allons utiliser l'application générique pour diffuser un contenu audio. Pas besoin de développement particulier et très suffisant en termes d'interface graphique.

L'application "télécommande" est appelée "Sender" et peut être réalisée sur iOS, Android ou Chrome. C'est sur cette dernière que nous allons concentrer nos efforts sur le reste de l'article. 

Attention, votre application ne reste pas forcément la seule capable de piloter la lecture et il faudra s'abonner aux différents changements : bouton physique du GoogleHome, notification Android de contrôle de la lecture, pilotage par la voix, il y a pleins de manière de maîtriser la force la lecture.

Déroulé fonctionnel d'une lecture sur ChromeCast

D'un point de vue utilisateur, voici ce qu'il se passe :

  1. Proposer un bouton "Cast" dans votre interface graphique ---> il faut utiliser un bouton proposé par le SDK,
  2. L'utilisateur clique sur le bouton, on lui propose une liste de périphérique disponibles --> fait par le SDK,
  3. En choisissant un device, une session est crée pour son utilisation ---> le SDK propose de s'abonner à ces événements,
  4. À l'aide de cette session, on charge un media à jouer sur le Cast --> développement à réaliser à l'aide des APIs du SDK,
  5. Une interface de pilotage de media est affichée dans l'application --> développement à réaliser,
  6. Les différentes actions pilotent la lecture sur le Cast ---> utilisation d'API du SDK.

Le SDK nous mâche donc beaucoup le travail et s'occupe de toute la partie connexion, communication avec le Cast et lecture de media. Tout le reste (interface graphique, choix des pistes à jouer, etc.) est un développement à notre charge.

Installation des dépendances

Au niveau de votre application Android, il faut installer deux packages nuget : Xamarin.GooglePlayServices.Cast et Xamarin.GooglePlayServices.Cast.Framework dans leur dernières versions. Les différentes dépendances nécessaires vous seront automatiquement proposées à l'installation. Sous le capot, Android utilise les APIs de MediaRouter et le package suivant sera donc installé en plus : Xamarin.Android.Support.v7.MediaRouter.

Capture.PNG

À noter qu'il faut cibler la version 9 du SDK au minimum pour pouvoir caster !

Ajouter le bouton Cast dans votre UI

Le bouton de choix du périphérique de diffusion d'un media est un grand classique sur Android et l'utilisateur devrait commencer à reconnaître son icône assez facilement. Il est possible de le placer dans la barre de navigation ou n'importe où au sein de votre UI en utilisant ce bout d'XML : 

 <android.support.v7.app.MediaRouteButton
       android:id="@+id/media_route_button"
       android:layout_width="wrap_content"
       android:layout_height="wrap_content"
       android:mediaRouteTypes="user"
       android:visibility="gone" />

Screenshot_2018-06-25-14-08-33-738_com.jonathanantoine.Podcasts.png

La deuxième étape consiste à le brancher à l'infrastructure Chrome Cast depuis votre code avec cette ligne de code :

var button = // récupération du bouton
CastButtonFactory.SetUpMediaRouteButton(Context, button);

Une fois ceci fait, le bouton apparaîtra en semi-transparence lorsqu'il n'est pas possible de caster (pas de périphériques compatibles trouvés) et en opacité complète lorsque c'est possible. Un click dessus ouvrira la fenêtre de choix de device de diffusion à l'utilisateur automatiquement, il n'y a rien d'autre à faire.

 

Screenshot_2018-06-25-21-05-37-964_com.jonathanantoine.Podcasts.png

Indiquer l'application Receiver à utiliser

Ici il est nécessaire de passer par l'enregistrement d'un OptionsProvider pour indiquer que l'on souhaite utiliser le receiver par défaut. on place pour cela un attribut Register avec le namespace complet de notre option provider en remplacant les points par des /

namespace Podcasts.Droid.Services
{
    [Register("Podcasts/Droid/Services/CastOptionsProvider")]
    public class CastOptionsProvider 
        : Java.Lang.Object, IOptionsProvider
    {
        public IList<SessionProvider> 
        GetAdditionalSessionProviders(Context appContext)
        {
            return null;
        }

        public CastOptions GetCastOptions(Context appContext)
        {
            return new CastOptions.Builder()
                  .SetReceiverApplicationId(
                      CastMediaControlIntent
                          .DefaultMediaReceiverApplicationId)
                                  .Build();
        }
    }
}

Récupération et utilisation du MediaSessionManager

Le MediaSessionManager est un objet permettant de ... gérer votre session ChromeCast. On va pouvoir l'obtenir simplement à l'aide de l'instance de la classe CastContext. Si les services Google Play ne sont pas installés ou à jour, une exception sera levée, profitez en pour afficher un message à l'utilisateur :

try
{
    _mediaSessionManager = CastContext
	  .GetSharedInstance(Android.App.Application.Context)
	  .SessionManager;
	  
    _isCastSupported = true;
}
catch (System.Exception ex)
{
    // GPS not here, no casting !
}

Une fois une instance de MediaSessionManager obtenue, on va pouvoir s'abonner aux différents éléments du cycle de vie d'une session en fournissant une classe implémentant l'interface ISessionManagerListener à la méthode AddSessionManagerListener. Il est conseillé de le faire sur le callback OnResume de votre activité et de se désabonner (avec RemoveSessionManagerListener) dans le callback OnPause. 

if(_mediaSessionManagerListener==null){
  _mediaSessionManagerListener =
            new PodcastSessionManagerListener();
}

_mediaSessionManager
   .AddSessionManagerListener(_mediaSessionManagerListener);

Le listener est une classe très simple dérivant de Java.Lang.Object implémentant une interface ISessionManagerListener définissant des méthodes dont le nom parle de lui même. Dans cette implémentation basique, j'affiche un Toast lors de la connexion et un autre lors de la déconnexion au ChromeCast. J'en profite aussi pour mettre à jour le statut d'un service fonctionnel de mon cru pour indiquer si je suis en train de caster ou non. Il est aussi nécessaire de prendre en compte les cas de "récupération de session" existante lors d'un redémarrage de l'application (les méthodes OnSessionResumed et OnSessionResumeFailed)

public class PodcastSessionManagerListener 
   : Java.Lang.Object, ISessionManagerListener
{
  public PodcastSessionManagerListener() { }

  public IAudioService TargetService { get; set; }

  public void OnSessionEnded(Object session, int error)
  {
    TargetService.IsCasting = false;
    Toast.MakeText(Android.App.Application.Context, 
	` "Session ended", ToastLength.Short).Show();
  }

  public void OnSessionEnding(Object session) { }

  public void OnSessionResumeFailed(Object session, int error)
  {
    TargetService.IsCasting = false;
  }

  public void OnSessionResumed(Object session, bool wasSuspended)
  {
    TargetService.IsCasting = true;
  }

  public void OnSessionResuming(Object session, string sessionId) { }

  public void OnSessionStartFailed(Object session, int error) { }

  public void OnSessionStarted(Object session, string sessionId)
  {
    Toast.MakeText(Android.App.Application.Context, "Session started", 
         ToastLength.Short).Show();
    TargetService.IsCasting = true;
  }

  public void OnSessionStarting(Object session) { }

  public void OnSessionSuspended(Object session, int reason) { }
}

 

Déclencher la première lecture d'un flux audio !

Au sein de la méthode OnSessionStarted, nous allons pouvoir caster (ha ha ha) l'objet Session en CastSession pour déclencher une lecture. Pour cela on va demander un chargement du media voulu et fournir les différentes métadonnées à notre disposition à la méthode LoadAsync de la propriété RemoteMediaClient porté par la CastSession. Dans l'exemple ci-dessous j'utilise un objet fonctionnel spécifique à mon application portant le doux nom de "item". Les différentes métadonnées sont fournies dans un dictionnaire en utilisant les constantes de la classe MediaMedata comme clefs. 

public static async Task StartPlayingToRemoteCastAsync
   (CastSession sessionCasted, bool autoPlay)
{
   try
   {
     var item = // objet custom représentant mon media à jouer;

     // on donne les meta infos que l'on connaît
     var meta = new MediaMetadata(MediaMetadata.MediaTypeMusicTrack);
     meta.PutString(MediaMetadata.KeyTitle, item.Data.Title);
     meta.PutString(MediaMetadata.KeyAlbumTitle, item.Data.PodcastTitle);
     meta.PutString(MediaMetadata.KeyArtist, item.Data.PodcastAuthor);
     meta.Images.Add(new WebImage(Android.Net.Uri.Parse(item.Image600)));

     var info = new MediaInfo.Builder(item.Data.MediaUrl)
        .SetStreamType(MediaInfo.StreamTypeBuffered)		
		//lecture d'un MP3
        .SetContentType("audio/mpeg")
		// durée du media en millisecondes
        .SetStreamDuration(item.Data.Duration * 1000)
        .SetMetadata(meta)
        .Build();

     // chargement de la piste avec lecture automatique ou non
     // à un emplacement spécifié
     await sessionCasted.RemoteMediaClient
	    .LoadAsync(info, autoplay: autoPlay,
           playPosition: item.Data.CurrentPosition * 1000);
   }
   catch (Exception e)
   {
      Toast.MakeText(Android.App.Application.Context,
     	  "Error " + e.Message, ToastLength.Short).Show();
   }
}

On le voit dans l'exemple ci-dessus, il est possible de demander au Cast de jouer tout de suite le media à un emplacement spécifique. Cela permet donc de passer de la lecture depuis votre téléphone sur le Cast de manière transparente.

IMG_20180625_210732.jpg

Obtenir l'état d'avancement de la lecture

Il est intéressant d'afficher l'état d'avancement de la lecture directement dans votre application Sender. Pour cela, on va devoir fournir une instance d'une classe implémentant IProgressListener à la méthode AddProgressListener de notre RemoteMediaClient. Le meilleur moment pour le faire est à la création de la MediaSession. On indique en deuxième paramètre la fréquence de mise à jour voulue en milliseconde. Dans la méthode OnSessionStarted et OnSessionResumed, on ajoute donc : 

if (session is CastSession sessionCasted)
{
  sessionCasted.RemoteMediaClient
   .AddProgressListener(
      new ChromeCastProgressListener(TargetService),
	  1000);
}

L'implémentation de la classe ChromeCastProgressListener est alors assez évidente, toujours en dérivant de Java.Lang.Object : 

public class ChromeCastProgressListener 
 : Object, RemoteMediaClient.IProgressListener
{
  public ChromeCastProgressListener(
     IAudioService targetService)
  {
    TargetService = targetService;
  }

  IAudioService TargetService { get; set; }

  public void OnProgressUpdated(long progressMs, long durationMs)
  {
    TargetService
	.UpdateWithNewCurrentlyPlayingPositionAsync(
	  (int)(progressMs / 1000L));
  }
}

 

Obtenir les changements d'état de la lecture

Il est aussi important de connaître l'état du player distant : est-il en train de lire le media, de le préparer (bufferisation), en pause, en erreur ? Là encore, cela passe par l'implémentation d'une interface IListener que l'on abonne sur le RemoteMediaClient.

if (session is CastSession sessionCasted)
{
  sessionCasted.RemoteMediaClient
   .AddListener(
      new ChromeCastRemoteMediaClientListener(
          sessionCasted.Rem,oteMediaClientTargetService)
	  );
}

L'implémentation est aussi assez naïve et consiste à lire les différentes propriétés du RemoteMediaClient pour savoir ce qu'il se passe. La seule astuce notable consiste à détecter la fin de la lecture d'une piste à l'aide des propriétés MediaStatus et IdleReason.

public class ChromeCastRemoteMediaClientListener 
  : Object, RemoteMediaClient.IListener
{
  IAudioService TargetService  { get; private set;}
  private readonly RemoteMediaClient _mediaClient;

  public ChromeCastRemoteMediaClientListener(
      IAudioService targetService, RemoteMediaClient mediaClient)
  {
    _mediaClient = mediaClient;
    TargetService = targetService;
  }

  public void OnAdBreakStatusUpdated(){}
  public void OnMetadataUpdated(){}
  public void OnPreloadStatusUpdated(){}
  public void OnQueueStatusUpdated(){}
  public void OnSendingRemoteMediaRequest(){}

  private bool _mediaFinishedSent = false;
  public void OnStatusUpdated()
  {
    var targetService = TargetService;
    if (targetService == null)
    {
      return;
    }

    if (_mediaClient.IsBuffering)
    {
      targetService.CastingPlayingStatus = PlayingState.Buffering;
    }
    else if (_mediaClient.IsPaused)
    {
      targetService.CastingPlayingStatus = PlayingState.Paused;
    }
    else if (_mediaClient.IsPlaying)
    {
      targetService.CastingPlayingStatus = PlayingState.Playing;
      _mediaFinishedSent = false;
    }
    else if (_mediaClient?.MediaStatus?.PlayerState
     == MediaStatus.PlayerStateIdle
        && _mediaClient?.MediaStatus?.IdleReason
     == MediaStatus.IdleReasonFinished)
    {
      if (!_mediaFinishedSent)
      {
        _mediaFinishedSent = true;
        targetService.FinishedPlayingAsync();
      }
    }

    if (_mediaClient?.MediaStatus?.PlayerState 
	    != MediaStatus.PlayerStateIdle)
    {
      _mediaFinishedSent = false;
    }
  }
}

Petit bonus pour les acharnés du détail : forcer la couleur du bouton de cast

Cela passe par ce bout de code et quelques APIs des librairies de support Google. On récupére le drawable correspondant à l'icône par défaut, on le clone et on lui applique une teinte choisie : 

var button = new MediaRouteButton(Context);

var castContext = new ContextThemeWrapper(
     Context, Resource.Style.Theme_MediaRouter);

var a = castContext.ObtainStyledAttributes(null,
          Resource.Styleable.MediaRouteButton,
          Resource.Attribute.mediaRouteButtonStyle, 0);
		  
var drawable = DrawableCompat.Wrap(a.GetDrawable(
    Resource.Styleable.MediaRouteButton_externalRouteEnabledDrawable)
	).Mutate();

a.Recycle();

var color = ContextCompat.GetColor
   (Context, Resource.Color.MainAccentColor);
DrawableCompat.SetTintMode(drawable, 
   Android.Graphics.PorterDuff.Mode.SrcAtop);
   
DrawableCompat.SetTint(drawable, color);

button.SetRemoteIndicatorDrawable(drawable);

Pour aller plus loin 

Nous avons maintenant une application fonctionnelle capable de diffuser du contenu sur un ChromeCast ou GoogleHome et ... cela reste toujours un peu magique pour moi :) Dans le cadre de mon application de suivi de Podcasts, cela est suffisant mais sachez qu'il reste plusieurs choses à creuser dans le SDK Cast à partir de la documentation :

  • La gestion des Playlist,
  • L'utilisation des éléments graphiques "MiniPlayer",
  • Le contrôle du volume,
  • etc.

Happy coding !

Photo de profil

Ces billets pourraient aussi vous intéresser

Vous nous direz ?!

Commentaires

comments powered by Disqus