管理 TV 用户交互

在直播电视体验中,用户更换频道,并在信息消失前短暂地看到频道和节目信息。其他类型的信息(如消息(“请勿在家模仿”)、字幕或广告)可能需要持续显示。与任何 TV 应用一样,此类信息不应干扰屏幕上播放的节目内容。

图 1. 直播电视应用中的叠加层消息。

此外,还要根据内容的分级和家长控制设置,考虑是否应呈现某些节目内容,以及当内容被禁播或无法观看时,您的应用的行为方式和通知用户。本课介绍如何根据这些考虑因素开发 TV 输入的用户体验。

请试用 TV 输入服务示例应用。

将播放器与 Surface 集成

TV 输入必须将视频渲染到 Surface 对象上,该对象由 TvInputService.Session.onSetSurface() 方法传递。以下示例展示了如何使用 MediaPlayer 实例播放 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;
}

同样,以下示例说明了如何使用 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;
}

使用叠加层

您可以使用叠加层来显示字幕、消息、广告或 MHEG-5 数据广播。默认情况下,叠加层处于停用状态。您可以在创建会话时通过调用 TvInputService.Session.setOverlayViewEnabled(true) 来启用它,如以下示例所示:

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;
}

对叠加层使用从 TvInputService.Session.onCreateOverlayView() 返回的 View 对象,如下所示:

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;
}

叠加层的布局定义可能如下所示:

<?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>

控制内容

当用户选择某个频道时,您的 TV 输入会处理 TvInputService.Session 对象中的 onTune() 回调。系统 TV 应用的家长控制功能会根据内容分级确定显示哪些内容。以下部分介绍了如何使用与系统 TV 应用通信的 TvInputService.Session notify 方法来管理频道和节目选择。

让视频无法播放

当用户切换频道时,您需要确保在 TV 输入呈现内容之前屏幕不会显示任何散乱的视频伪像。调用 TvInputService.Session.onTune() 时,您可以通过调用 TvInputService.Session.notifyVideoUnavailable() 并传递 VIDEO_UNAVAILABLE_REASON_TUNING 常量来阻止呈现视频,如以下示例所示。

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;
}

然后,当内容呈现到 Surface 时,您可以调用 TvInputService.Session.notifyVideoAvailable() 以允许显示视频,如下所示:

Kotlin

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

Java

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

这种过渡仅持续几分之一秒的时间,但呈现空白屏幕在视觉上比允许照片闪烁奇怪的闪烁和抖动效果更佳。

另请参阅将播放器与 surface 集成,详细了解如何使用 Surface 呈现视频。

提供家长控制

如需确定指定内容是否被家长控制和内容分级屏蔽,请检查 TvInputManager 类方法 isParentalControlsEnabled()isRatingBlocked(android.media.tv.TvContentRating)。您可能还需要确保内容的 TvContentRating 包含在一组当前允许的内容分级中。以下示例显示了这些考虑因素。

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);
}

确定是否应屏蔽相应内容后,请通过调用 TvInputService.Session 方法 notifyContentAllowed()notifyContentBlocked() 通知系统 TV 应用,如上例所示。

使用 TvContentRating 类,通过 TvContentRating.createRating() 方法为 COLUMN_CONTENT_RATING 生成系统定义的字符串,如下所示:

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");

处理轨道选择

TvTrackInfo 类包含有关媒体轨道的信息,例如轨道类型(视频、音频或字幕)等。

您的 TV 输入会话首次能够获取轨道信息时,应调用 TvInputService.Session.notifyTracksChanged() 并提供所有轨道的列表,以更新系统 TV 应用。当轨道信息发生更改时,请再次调用 notifyTracksChanged() 以更新系统。

系统 TV 应用提供一个界面,当给定轨道类型(例如,不同语言的字幕)有多个轨道时,用户可选择特定轨道。您的 TV 输入通过调用 notifyTrackSelected() 来响应来自系统 TV 应用的 onSelectTrack() 调用,如以下示例所示。请注意,将 null 作为轨道 ID 传递时,会取消选择相应轨道。

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;
}