TV 사용자 상호작용 관리

실시간 TV 환경에서 사용자가 채널을 변경하면 정보가 사라지기 전에 채널 및 프로그램 정보가 잠시 표시됩니다. 메시지 ('DO NOT ATTEMPT AT HOME'), 자막 또는 광고와 같은 다른 유형의 정보는 계속 유지해야 할 수도 있습니다. 다른 TV 앱과 마찬가지로 이러한 정보는 화면에서 재생되는 프로그램 콘텐츠를 방해해서는 안 됩니다.

그림 1. 실시간 TV 앱의 오버레이 메시지

또한 콘텐츠 등급 및 자녀 보호 기능 설정을 고려하여 특정 프로그램 콘텐츠를 표시해야 하는지, 콘텐츠가 차단되거나 사용할 수 없을 때 앱이 동작하고 사용자에게 알리는 방식을 고려하세요. 이 과정에서는 이러한 고려사항에 맞게 TV 입력의 사용자 환경을 개발하는 방법을 설명합니다.

TV 입력 서비스 샘플 앱을 사용해 보세요.

플레이어를 표면과 통합

TV 입력은 TvInputService.Session.onSetSurface() 메서드에 의해 전달되는 Surface 객체에 동영상을 렌더링해야 합니다. 다음은 Surface 객체에서 콘텐츠를 재생하기 위해 MediaPlayer 인스턴스를 사용하는 방법을 보여주는 예입니다.

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

다음에 나와 있는 것처럼 View에서 반환된 TvInputService.Session.onCreateOverlayView() 객체를 오버레이에 사용합니다.

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

이 전환은 1초 내에만 지속되지만 빈 화면을 표시하는 것이 이상한 신호와 잡음을 표시하는 것보다는 시각적으로 더 좋습니다.

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