Cómo administrar la interacción del usuario de TV

En la experiencia de TV en vivo, cuando el usuario cambia de canal, se le muestra brevemente información sobre el canal y el programa, que luego desaparece. Sin embargo, es posible que algunos tipos de información no desaparezcan, como mensajes ("NO LO INTENTES EN TU CASA"), subtítulos o anuncios. Como con cualquier app de TV, esta información no debe interferir con el contenido del programa que se reproduce en la pantalla.

Figura 1: Un mensaje de superposición en una app de TV en vivo

Considera también si se deberá presentar determinado contenido del programa, según la configuración de los controles parentales y la clasificación del contenido, y la manera en que la app se comporta y le informa al usuario cuando el contenido se bloquea o no está disponible. En esta lección, se describe cómo desarrollar la experiencia del usuario de entrada de TV teniendo en cuenta estas consideraciones.

Prueba la app de ejemplo de TV Input Service.

Cómo integrar el jugador con la plataforma

La entrada de TV debe procesar el video en un objeto Surface, que se pasa por el método TvInputService.Session.onSetSurface(). Este es un ejemplo de cómo usar una instancia MediaPlayer para reproducir contenido en el objeto Surface:

Kotlin

    override fun onSetSurface(surface: Surface?): Boolean {
        player?.setSurface(surface)
        mSurface = surface
        return true
    }

    override fun onSetStreamVolume(volume: Float) {
        player?.setVolume(volume, volume)
        mVolume = volume
    }
    

Java

    @Override
    public boolean onSetSurface(Surface surface) {
        if (player != null) {
            player.setSurface(surface);
        }
        mSurface = surface;
        return true;
    }

    @Override
    public void onSetStreamVolume(float volume) {
        if (player != null) {
            player.setVolume(volume, volume);
        }
        mVolume = volume;
    }
    

De manera similar, aquí se muestra cómo hacerlo con ExoPlayer:

Kotlin

    override fun onSetSurface(surface: Surface?): Boolean {
        player?.createMessage(videoRenderer)?.apply {
            type = MSG_SET_SURFACE
            payload = surface
            send()
        }
        mSurface = surface
        return true
    }

    override fun onSetStreamVolume(volume: Float) {
        player?.createMessage(audioRenderer)?.apply {
            type = MSG_SET_VOLUME
            payload = volume
            send()
        }
        mVolume = volume
    }
    

Java

    @Override
    public boolean onSetSurface(@Nullable Surface surface) {
        if (player != null) {
            player.createMessage(videoRenderer)
                    .setType(MSG_SET_SURFACE)
                    .setPayload(surface)
                    .send();
        }
        mSurface = surface;
        return true;
    }

    @Override
    public void onSetStreamVolume(float volume) {
        if (player != null) {
            player.createMessage(videoRenderer)
                    .setType(MSG_SET_VOLUME)
                    .setPayload(volume)
                    .send();
        }
        mVolume = volume;
    }
    

Cómo usar una superposición

Usa una superposición para mostrar subtítulos, mensajes, anuncios o emisiones de datos MHEG-5. De forma predeterminada, la superposición está inhabilitada. Puedes habilitarla cuando crees la sesión llamando a TvInputService.Session.setOverlayViewEnabled(true), como en el siguiente ejemplo:

Kotlin

    override fun onCreateSession(inputId: String): Session =
            onCreateSessionInternal(inputId).apply {
                setOverlayViewEnabled(true)
                sessions.add(this)
            }
    

Java

    @Override
    public final Session onCreateSession(String inputId) {
        BaseTvInputSessionImpl session = onCreateSessionInternal(inputId);
        session.setOverlayViewEnabled(true);
        sessions.add(session);
        return session;
    }
    

Usa un objeto View para la superposición que muestra TvInputService.Session.onCreateOverlayView(), como se muestra a continuación:

Kotlin

    override fun onCreateOverlayView(): View =
            (context.getSystemService(LAYOUT_INFLATER_SERVICE) as LayoutInflater).run {
                inflate(R.layout.overlayview, null).apply {
                    subtitleView = findViewById<SubtitleView>(R.id.subtitles).apply {
                        // Configure the subtitle view.
                        val captionStyle: CaptionStyleCompat =
                                CaptionStyleCompat.createFromCaptionStyle(captioningManager.userStyle)
                        setStyle(captionStyle)
                        setFractionalTextSize(captioningManager.fontScale)
                    }
                }
            }
    

Java

    @Override
    public View onCreateOverlayView() {
        LayoutInflater inflater = (LayoutInflater) getSystemService(LAYOUT_INFLATER_SERVICE);
        View view = inflater.inflate(R.layout.overlayview, null);
        subtitleView = (SubtitleView) view.findViewById(R.id.subtitles);

        // Configure the subtitle view.
        CaptionStyleCompat captionStyle;
        captionStyle = CaptionStyleCompat.createFromCaptionStyle(
                captioningManager.getUserStyle());
        subtitleView.setStyle(captionStyle);
        subtitleView.setFractionalTextSize(captioningManager.fontScale);
        return view;
    }
    

La definición del diseño de la superposición será similar a la siguiente:

    <?xml version="1.0" encoding="utf-8"?>
    <FrameLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"

        android:layout_width="match_parent"
        android:layout_height="match_parent">

        <com.google.android.exoplayer.text.SubtitleView
            android:id="@+id/subtitles"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="bottom|center_horizontal"
            android:layout_marginLeft="16dp"
            android:layout_marginRight="16dp"
            android:layout_marginBottom="32dp"
            android:visibility="invisible"/>
    </FrameLayout>
    

Cómo controlar el contenido

Cuando el usuario selecciona un canal, tu entrada de TV controla la devolución de llamada de onTune() en el objeto TvInputService.Session. Los controles parentales de la app de TV del sistema determinan qué contenido se muestra, según la clasificación del contenido. En las siguientes secciones, se describe cómo administrar la selección de canales y programas con los métodos TvInputService.Session notify que se comunican con la app de TV del sistema.

Cómo poner un video como no disponible

Si el usuario cambia de canal, asegúrate de que la pantalla no muestre artefactos de video sueltos antes de que la entrada de TV procese el contenido. Cuando llamas a TvInputService.Session.onTune(), puedes evitar que el video se presente llamando a TvInputService.Session.notifyVideoUnavailable() y pasando la constante VIDEO_UNAVAILABLE_REASON_TUNING, como se muestra en el siguiente ejemplo.

Kotlin

    override fun onTune(channelUri: Uri): Boolean {
        subtitleView?.visibility = View.INVISIBLE
        notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING)
        unblockedRatingSet.clear()

        dbHandler.apply {
            removeCallbacks(playCurrentProgramRunnable)
            playCurrentProgramRunnable = PlayCurrentProgramRunnable(channelUri)
            post(playCurrentProgramRunnable)
        }
        return true
    }
    

Java

    @Override
    public boolean onTune(Uri channelUri) {
        if (subtitleView != null) {
            subtitleView.setVisibility(View.INVISIBLE);
        }
        notifyVideoUnavailable(TvInputManager.VIDEO_UNAVAILABLE_REASON_TUNING);
        unblockedRatingSet.clear();

        dbHandler.removeCallbacks(playCurrentProgramRunnable);
        playCurrentProgramRunnable = new PlayCurrentProgramRunnable(channelUri);
        dbHandler.post(playCurrentProgramRunnable);
        return true;
    }
    

Luego, cuando el contenido se procesa en el Surface, se llama a TvInputService.Session.notifyVideoAvailable() para permitir que el video se muestre de la siguiente manera:

Kotlin

    fun onRenderedFirstFrame(surface:Surface) {
        firstFrameDrawn = true
        notifyVideoAvailable()
    }
    

Java

    @Override
    public void onRenderedFirstFrame(Surface surface) {
        firstFrameDrawn = true;
        notifyVideoAvailable();
    }
    

Esta transición dura fracciones de segundo, pero presentar una pantalla en blanco es visualmente mejor que permitir que la imagen tiemble.

Consulta también Cómo integrar el reproductor con la superficie a fin de obtener más información sobre cómo trabajar con Surface para procesar videos.

Cómo proporcionar controles parentales

Para determinar si un contenido determinado está bloqueado por controles parentales y calificación de contenido, verifica los métodos de clase TvInputManager, isParentalControlsEnabled() y isRatingBlocked(android.media.tv.TvContentRating). Es posible que también quieras asegurarte de que el TvContentRating del contenido esté incluido en un conjunto de calificaciones permitidas. Estas consideraciones se muestran en el siguiente ejemplo.

Kotlin

    private fun checkContentBlockNeeded() {
        currentContentRating?.also { rating ->
            if (!tvInputManager.isParentalControlsEnabled
                    || !tvInputManager.isRatingBlocked(rating)
                    || unblockedRatingSet.contains(rating)) {
                // Content rating is changed so we don't need to block anymore.
                // Unblock content here explicitly to resume playback.
                unblockContent(null)
                return
            }
        }
        lastBlockedRating = currentContentRating
        player?.run {
            // Children restricted content might be blocked by TV app as well,
            // but TIF should do its best not to show any single frame of blocked content.
            releasePlayer()
        }

        notifyContentBlocked(currentContentRating)
    }
    

Java

    private void checkContentBlockNeeded() {
        if (currentContentRating == null || !tvInputManager.isParentalControlsEnabled()
                || !tvInputManager.isRatingBlocked(currentContentRating)
                || unblockedRatingSet.contains(currentContentRating)) {
            // Content rating is changed so we don't need to block anymore.
            // Unblock content here explicitly to resume playback.
            unblockContent(null);
            return;
        }

        lastBlockedRating = currentContentRating;
        if (player != null) {
            // Children restricted content might be blocked by TV app as well,
            // but TIF should do its best not to show any single frame of blocked content.
            releasePlayer();
        }

        notifyContentBlocked(currentContentRating);
    }
    

Una vez que hayas determinado si el contenido debe o no bloquearse, notifica a la app de TV del sistema llamando al método TvInputService.Session notifyContentAllowed() o notifyContentBlocked(), como se muestra en el ejemplo anterior.

Usa la clase TvContentRating a fin de generar la string definida por el sistema para COLUMN_CONTENT_RATING con el método TvContentRating.createRating(), como se muestra a continuación:

Kotlin

    val rating = TvContentRating.createRating(
            "com.android.tv",
            "US_TV",
            "US_TV_PG",
            "US_TV_D", "US_TV_L"
    )
    

Java

    TvContentRating rating = TvContentRating.createRating(
        "com.android.tv",
        "US_TV",
        "US_TV_PG",
        "US_TV_D", "US_TV_L");
    

Cómo controlar la selección de pistas

La clase TvTrackInfo conserva la información sobre las pistas multimedia, como el tipo de pista (video, audio o subtítulo), etcétera.

La primera vez que la sesión de entrada de TV puede obtener información de la pista, debe llamar a TvInputService.Session.notifyTracksChanged() con una lista de todas las pistas a fin de actualizar la aplicación de TV del sistema. Cuando haya un cambio en la información de la pista, vuelve a llamar a notifyTracksChanged() para actualizar el sistema.

La app de TV del sistema proporciona una interfaz a fin de que el usuario seleccione una pista específica si hay disponible más de una pista para un tipo de pista determinado; por ejemplo, subtítulos en diferentes idiomas. Tu entrada de TV responde a la llamada onSelectTrack() desde la app de TV del sistema llamando a notifyTrackSelected(), como se muestra en el siguiente ejemplo. Ten en cuenta que cuando se pasa null como el ID de pista, se anula la selección de la pista.

Kotlin

    override fun onSelectTrack(type: Int, trackId: String?): Boolean =
            mPlayer?.let { player ->
                if (type == TvTrackInfo.TYPE_SUBTITLE) {
                    if (!captionEnabled && trackId != null) return false
                    selectedSubtitleTrackId = trackId
                    subtitleView.visibility = if (trackId == null) View.INVISIBLE else View.VISIBLE
                }
                player.trackInfo.indexOfFirst { it.trackType == type }.let { trackIndex ->
                    if( trackIndex >= 0) {
                        player.selectTrack(trackIndex)
                        notifyTrackSelected(type, trackId)
                        true
                    } else false
                }
            } ?: false
    

Java

    @Override
    public boolean onSelectTrack(int type, String trackId) {
        if (player != null) {
            if (type == TvTrackInfo.TYPE_SUBTITLE) {
                if (!captionEnabled && trackId != null) {
                    return false;
                }
                selectedSubtitleTrackId = trackId;
                if (trackId == null) {
                    subtitleView.setVisibility(View.INVISIBLE);
                }
            }
            int trackIndex = -1;
            MediaPlayer.TrackInfo[] trackInfos = player.getTrackInfo();
            for (int index = 0; index < trackInfos.length; index++) {
                MediaPlayer.TrackInfo trackInfo = trackInfos[index];
                if (trackInfo.getTrackType() == type) {
                    trackIndex = index;
                    break;
                }
            }
            if (trackIndex >= 0) {
                player.selectTrack(trackIndex);
                notifyTrackSelected(type, trackId);
                return true;
            }
        }
        return false;
    }