SDK web experimental de Flutter para la Sala de juegos de YouTube

En esta guía, se explica cómo usar el SDK de YouTube Playables con una app web de Flutter.

Configuración de Flutter

La app web de Flutter predeterminada requiere algunos cambios para funcionar como un elemento de juego. Los siguientes cambios son obligatorios a partir de la versión 3.24.5 de Flutter.

De forma predeterminada, Flutter está configurado para cargar recursos desde el directorio raíz, mientras que Playables requiere que los recursos se carguen en relación con el punto de entrada. Aparecerá como un error 404 o, posiblemente, como un error que aparece en la consola de JavaScript que dice: Refused to execute script from... because its MIME type ('text/html') is not executable. Una forma de volver a configurar esto es quitar la siguiente etiqueta del archivo index.html:

<base href="$FLUTTER_BASE_HREF">

De forma predeterminada, Flutter carga algunas bibliotecas de forma dinámica en lugar de incorporarlas en la app. CanvasKit es un ejemplo de esto. Esto aparecerá como un error en la consola de JavaScript que dice: Refused to connect to '...' because it violates the following Content Security Policy directive: "connect-src 'self' blob: data:". Flutter también puede generar registros adicionales cuando falla la carga, como Failed to download any of the following CanvasKit URLs. Para solucionar este problema, puedes compilar tu app web con la siguiente marca adicional:

$ flutter build web --no-web-resources-cdn

De forma predeterminada, las apps web de Flutter cargan fuentes de forma dinámica en lugar de incorporarlas en la app. La app de Flutter predeterminada usa la fuente Roboto, y esto aparecerá como un error en la consola de JavaScript que dice lo siguiente: Refused to connect to '...' because it violates the following Content Security Policy directive: "connect-src 'self' blob: data". Para solucionar este problema, puedes seguir estos pasos:

  • Crea una carpeta llamada "fonts" en la raíz de tu app web de Flutter.
  • Descarga la familia de fuentes Roboto desde fonts.google.com.
  • Extrae las fuentes y copia Roboto-Regular.ttf en la carpeta de fuentes.
  • Agrega la siguiente línea al archivo pubspec.yaml de tu app web de Flutter:
  fonts:
    - family: Roboto
      fonts:
       - asset: fonts/Roboto-Regular.ttf

Puedes encontrar más información sobre la configuración de fuentes en la documentación de Flutter.

Integración de SDK

El SDK de YouTube Playables se puede usar desde un juego web de Flutter a través de un wrapper de interoperabilidad de JS similar a este:

ytgame.dart

// Copyright 2023 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

@JS()
library ytgame_api;

import 'package:js/js.dart';
import 'package:js/js_util.dart' as js_util;

enum PlayablesErrorType {
  unknown('UNKNOWN'),
  apiUnavailable('API_UNAVAILABLE'),
  invalidParams('INVALID_PARAMS'),
  sizeLimitExceeded('SIZE_LIMIT_EXCEEDED');

  const PlayablesErrorType(String type) : _type = type;
  final String _type;

  @override
  String toString() => _type;

  static PlayablesErrorType fromString(String errorType) {
    return values.firstWhere(
      (element) => element._type == errorType,
    );
  }
}

@JS()
@staticInterop
class PlayablesError {}

extension PlayablesErrorExtension on PlayablesError {
  @JS('errorType')
  external String get _errorType;
  PlayablesErrorType get errorType {
    return PlayablesErrorType.fromString(_errorType);
  }
}

@JS()
@anonymous
@staticInterop
class PlayablesScore {
  external factory PlayablesScore({
    required num value,
  });
}

extension PlayablesScoreExtension on PlayablesScore {
  external num get value;
}

// engagement
@JS()
@staticInterop
class PlayablesEngagement {}

extension PlayablesEngagementExtension on PlayablesEngagement {
  @JS('sendScore')
  external Object _sendScore(PlayablesScore score);
  Future<void> sendScore(PlayablesScore score) {
    return js_util.promiseToFuture(_sendScore(score));
  }
}

// game
@JS()
@staticInterop
class PlayablesGame {}

extension PlayablesGameExtension on PlayablesGame {
  external void firstFrameReady();
  external void gameReady();

  @JS('loadData')
  external Object _loadData();
  Future<String?> loadData() {
    return js_util.promiseToFuture<String?>(_loadData());
  }

  @JS('saveData')
  external Object _saveData(String? data);
  Future<void> saveData(String? data) {
    return js_util.promiseToFuture<void>(_saveData(data));
  }
}

// health
@JS()
@staticInterop
class PlayablesHealth {}

extension PlayablesHealthExtension on PlayablesHealth {
  external void logError();
  external void logWarning();
}

// system
typedef PlayablesUnsetCallback = void Function();
typedef PlayablesOnAudioEnabledChange = void Function(bool isAudioEnabled);
typedef PlayablesOnPause = void Function();
typedef PlayablesOnResume = void Function();

@JS()
@staticInterop
class PlayablesSystem {}

extension PlayablesSystemExtension on PlayablesSystem {
  external bool isAudioEnabled();

  @JS('onAudioEnabledChange')
  external PlayablesUnsetCallback _onAudioEnabledChange(
      PlayablesOnAudioEnabledChange callback);
  PlayablesUnsetCallback onAudioEnabledChange(
      PlayablesOnAudioEnabledChange callback) {
    return _onAudioEnabledChange(allowInterop(callback));
  }

  @JS('onPause')
  external PlayablesUnsetCallback _onPause(PlayablesOnPause callback);
  PlayablesUnsetCallback onPause(PlayablesOnPause callback) {
    return _onPause(allowInterop(callback));
  }

  @JS('onResume')
  external PlayablesUnsetCallback _onResume(PlayablesOnResume callback);
  PlayablesUnsetCallback onResume(PlayablesOnResume callback) {
    return _onResume(allowInterop(callback));
  }
}

@JS()
@staticInterop
class Playables {}

extension PlayablesExtension on Playables {
  @JS('SDK_VERSION')
  external String get sdkVersion;
  external PlayablesEngagement get engagement;
  external PlayablesGame get game;
  external PlayablesHealth get health;
  external PlayablesSystem get system;
}

@JS('ytgame')
external Playables get ytgame;

Uso

  1. Sigue las instrucciones para configurar e inicializar el SDK web.
    • Modifica el web/index.html de tu app web de Flutter para incluir las etiquetas de secuencia de comandos requeridas.
  2. Agrega una copia de ytgame.dart en el src de tu app web de Flutter.
  3. Asegúrate de que el paquete js se agregue a Flutter. Para ello, ejecuta el comando flutter pub add js.
  4. Agrega import 'src/location/of/ytgame.dart'; donde sea necesario.
  5. Usa el objeto ytgame para acceder al SDK.

Ejemplos

A continuación, se muestran algunos ejemplos de la API de Dart en acción.

firstFrameReady y gameReady

Para que el juego se inicie correctamente, deberás llamar a firstFrameReady cuando el primer fotograma esté listo para mostrarse y a gameReady cuando el juego esté listo para interactuar. Una forma de hacerlo en la app predeterminada de Flutter es agregar las llamadas al archivo main.dart:

...
import 'src/ytgame.dart';
...

void main() {
  ytgame.game.firstFrameReady();
  ytgame.game.gameReady();
  runApp(const MyApp());
}

Ten en cuenta que este es solo un ejemplo para que tu juego se ejecute. Deberás ubicar correctamente la ubicación de estas llamadas para proporcionar la implementación adecuada.

sendScore

En este ejemplo, se muestra una implementación de sendScore(int points) en Dart:

...
import 'src/ytgame.dart';
...

/// Sends [points] as score to YT Playables
Future<void> sendScore(int points) async {
  // Create a score object...
  final PlayablesScore score = PlayablesScore(
    value: points,
  );
  // Submit the score to ytgame
  await ytgame.engagement.sendScore(score);
}

onPause

Este es un ejemplo de cómo un juego puede escuchar eventos Pause provenientes de YT Playables para pausar su motor cuando sea necesario:

...
import 'src/ytgame.dart';
...

/// The instance of your game.
late Game myGame;

/// Callback to stop listening for Pause events (cleanup).
PlayablesUnsetCallback? _unsetOnPause;
...

/// Sets a [callback] to respond to Pause events.
void registerOnPauseListener(PlayablesOnPause callback) {
  _unsetOnPause = ytgame.system.onPause(callback);
}

/// Stops listening to Pause events, and clears [_unsetOnPause].
void unsetOnPauseListener() {
  if (_unsetOnPause != null) {
    _unsetOnPause!();
    _unsetOnPause = null;
  }
}

...

/// Initialization for your game
void initGame() {
  ...
  myGame = ...
  registerOnPauseListener(_onPause);
}

void _onPause() {
  // This is called when a Pause event is received from YT Playables.
  myGame.pauseEngine();
}

/// Teardown for your game
void disposeGame() {
  ...
  unsetOnPauseListener();
}

Consulta la referencia completa de la API de YT Playables.