管理 TV 用户交互

在直播 TV 体验中,用户换频道后,系统会为其呈现频道和节目信息,这些信息的显示时间短暂,很快就会消失。其他类型的信息(如消息“请勿在家模仿”、字幕或广告)可能需要一直显示。与任何 TV 应用一样,此类信息不应干扰屏幕上播放的节目内容。

图 1. 直播 TV 应用中的叠加消息。

此外,还应根据内容的分级和家长控制设置,考虑是否应呈现某些节目内容,以及在内容被禁播或无法观看时您的应用如何表现并通知用户。本课介绍如何根据这些考虑因素开发 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;
    }