إدارة تفاعل مستخدمي التلفزيون

في تجربة البث التلفزيوني المباشر، يغيّر المستخدم القنوات وتظهر له معلومات القناة والبرنامج لفترة وجيزة قبل أن تختفي. قد تحتاج إلى الاحتفاظ بأنواع أخرى من المعلومات، مثل الرسائل ("لا تحاول تنفيذ ذلك في المنزل") أو الترجمة أو الإعلانات. وكما هو الحال مع أي تطبيق تلفزيون، يجب ألا تتداخل هذه المعلومات مع محتوى البرنامج المعروض على الشاشة.

الشكل 1. رسالة تراكب في تطبيق تلفزيون مباشر

يجب أيضًا مراعاة ما إذا كان يجب عرض محتوى معيّن من البرنامج، وذلك وفقًا لتقييم المحتوى وإعدادات رقابة الأهل، وكيفية تصرّف تطبيقك وإعلام المستخدم عند حظر المحتوى أو عدم توفّره. توضّح هذه الدرس كيفية تطوير تجربة المستخدم الخاصة بإدخال التلفزيون مع مراعاة هذه الاعتبارات.

جرِّب تطبيق TV Input Service النموذجي.

دمج اللاعب مع مساحة العرض

يجب أن يعرض إدخال التلفزيون الفيديو على العنصر 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;
}

استخدِم عنصر 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>

التحكّم في المحتوى

عندما يختار المستخدم قناة، يتعامل إدخال التلفزيون مع معاودة الاتصال onTune() في العنصر TvInputService.Session. تحدّد أدوات رقابة الأهل في تطبيق بث تلفزيوني التابع للنظام المحتوى الذي يتم عرضه، وذلك استنادًا إلى التقييم حسب الفئة العمرية للمحتوى. توضّح الأقسام التالية كيفية إدارة عملية اختيار القنوات والبرامج باستخدام طريقتَي TvInputService.Session وnotify اللتين تتواصلان مع تطبيق بث تلفزيوني على النظام.

إتاحة الفيديو

عندما يغيّر المستخدم القناة، عليك التأكّد من أنّ الشاشة لا تعرض أي تشويش في الفيديو قبل أن يعرض إدخال التلفزيون المحتوى. عند الاتصال بـ 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 لعرض الفيديو.

توفير أدوات رقابة الأهل

لتحديد ما إذا كان محتوى معيّنًا محظورًا بسبب أدوات رقابة الأهل والتقييم حسب الفئة العمرية، يمكنك التحقّق من طرق الفئة 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);
}

بعد تحديد ما إذا كان يجب حظر المحتوى أم لا، أرسِل إشعارًا إلى تطبيق TV النظام من خلال استدعاء الطريقة TvInputService.Session أو notifyContentAllowed() أو notifyContentBlocked() ، كما هو موضّح في المثال السابق.

استخدِم الفئة TvContentRating لإنشاء السلسلة المحدّدة من خلال النظام الخاصة بـ COLUMN_CONTENT_RATING باستخدام الطريقة TvContentRating.createRating()، كما هو موضّح هنا:

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 على معلومات حول مسارات الوسائط، مثل نوع المسار (فيديو أو صوت أو ترجمة وشرح) وما إلى ذلك.

في المرة الأولى التي يمكن فيها لجلسة إدخال التلفزيون الحصول على معلومات حول المسار، يجب أن تستدعي TvInputService.Session.notifyTracksChanged() مع قائمة بجميع المسارات لتعديل تطبيق بث تلفزيوني على النظام. وعند حدوث تغيير في معلومات المسار، يجب استدعاء notifyTracksChanged() مرة أخرى لتعديل النظام.

يوفر تطبيق نظام التلفزيون واجهة للمستخدم لاختيار مقطع صوتي معيّن إذا كان يتوفّر أكثر من مقطع صوتي واحد لنوع مقطع صوتي معيّن، مثل الترجمة والشرح بلغات مختلفة. يستجيب إدخال التلفزيون لطلب onSelectTrack() من تطبيق نظام التلفزيون من خلال استدعاء notifyTrackSelected() ، كما هو موضّح في المثال التالي. يُرجى العِلم أنّه عند إدخال null كرقم تعريف المسار، سيؤدي ذلك إلى إلغاء اختيار المسار.

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