开发者控制台

直播TV资源

直播TV资源

以下最佳实践、代码示例和其他参考将帮助您更好地了解直播集成在实现阶段的细节。

将程序包添加到允许列表

允许列表确定了哪些应用能够在Fire TV浏览和搜索体验中显示其频道。

最佳实践

以下产品和实施指南将为您的客户提供最佳的Fire TV电视直播体验:

  • 提供无冲突注册,鼓励在适用情况下试用。例如简化应用上的注册表单或使用电话号码进行注册。
  • 对频道阵容中的每个TvContract.Channels.Logo使用透明的单色标志。
  • 优化深层链接流,以在2.5秒内开始全屏播放。
  • 操作多个频道时,使用批量操作。
  • 在适用情况下使用Gracenote频道ID,以简化集成。
  • 注重元数据加载性能优化,而不是提供完整节目单或偏爱的图像大小。
  • 使用JobSchedulerWorkManager定期检查并确保授权始终准确。即使您的应用未在前台运行,这也可以确保浏览和搜索中的频道与实际的授权频道始终同步。
  • 除了自定义频道排序的情况之外,在授权频道列表稍有变化时,最佳办法是更新现有的授权频道列表,而不是删除并重新添加所有频道。
  • COLUMN_DISPLAY_NAME中提供将在Fire TV UI中显示的频道显示名称。Fire TV最多显示25个字母数字字符,但如果长度超过此限制,则不会显示完整的频道名称。此最大数目限制适用于半角和全角字符集。以下为是否能显示的示例: The Walking Dead Universe(最大长度-通过)/简短名称(通过)/特别长的站名(失败)/韓流・華流韓流・華流韓(最大长度-通过)。
  • 每次执行修改之前,请先查询TIF数据库,确定数据库内已有哪些频道。
  • 插入频道之前,请确保该频道尚不存在。如果该频道已存在,请检查确认元数据是不是最新的。仅当元数据需要更新时,才应该执行数据库更新操作。
  • 应检查确认数据库游标为空。如果游标为空,请针对所有带有输入ID的频道发送删除请求,然后重新插入频道。

代码示例

本节包含与直播TV集成相关的代码示例。

TVContractUtils中的以下代码显示了如何将Gracenote ID和深层链接添加到电视数据库中。

/**
 * 用于存储外部ID类型的变量,用于匹配的服务元数据。有效类型为
 * 下面定义为带有前缀“EXTERNAL_ID_TYPE_”的常量
 *空或无效数据将导致
 *元数据的服务匹配失败
 */
private final static String EXTERNAL_ID_TYPE = "externalIdType";

/**
 * 用于存储外部ID的值的变量,用于匹配的服务元数据。
 * 空值或无效数据将导致元数据的服务匹配失败
 */
private final static String EXTERNAL_ID_VALUE = "externalIdValue";

/**
 * 用于在外部播放器中插入播放的深层链接的URI。
 * 空或无效数据将导致默认与Fire TV原生播放器集成
 */
private final static String PLAYBACK_DEEP_LINK_URI = "playbackDeepLinkUri";

// Gracenote输入类型的ID
private final static String GRACENOTE_ID = "gracenote_ontv"; // gracenote ontv id
private final static String GRACENOTE_GVD = "gracenote_gvd"; // gracenote gvd id

// 播放深层链接URI的合约
// 使用Intent.URI_INTENT_SCHEME从意图创建URI并转换回原始意图
Intent playbackDeepLinkIntent = new Intent(); // 由您的应用创建
String playbackDeepLinkUri = playbackDeepLinkIntent.toUri(Intent.URI_INTENT_SCHEME);

// 构建BLOB
ContentValues values = new ContentValues();  // 存储所有频道数据
ContentResolver resolver = context.getContentResolver();
values.put(TvContract.Channels.COLUMN_DISPLAY_NAME, "#Actual display name#");
values.put(TvContract.Channels.COLUMN_INPUT_ID, "#Actual input id#");
try {
    String jsonString = new JSONObject()
                  .put(EXTERNAL_ID_TYPE, "#Actual Id Type#") // 替换为GRACENOTE_XXX
                  .put(EXTERNAL_ID_VALUE, "#Actual Id Value#") // 替换为与频道关联的gracenote ID值
                  .put(PLAYBACK_DEEP_LINK_URI, playbackDeepLinkUri).toString();

    values.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA, jsonString.getBytes());
} catch (JSONException e) {
    Log.e(TAG, "Error when adding data to blob " + e);
}

Uri uri = resolver.insert(TvContract.Channels.CONTENT_URI, values);
import android.app.Activity
import android.content.ContentValues
import android.content.Intent
import android.media.tv.TvContract
import android.util.Log
import org.json.JSONException
import org.json.JSONObject

/**
 * 用于存储外部ID类型的变量,用于匹配的服务元数据。有效
 *类型在下面定义为前缀为"EXTERNAL_ID_TYPE_"的常量空值或无效数据将
 *导致元数据服务匹配失败
 */
private const val EXTERNAL_ID_TYPE = "externalIdType"
/**
 * 用于存储外部ID的值的变量,用于匹配的服务元数据。空值
 *或无效数据将导致元数据的服务匹配失败
 */
private const val EXTERNAL_ID_VALUE = "externalIdValue"
/**
 * 用于在外部播放器中插入播放的深层链接的URI。空值或无效数据将导致默认
 *与Fire TV原生播放器集成
 */
private const val PLAYBACK_DEEP_LINK_URI = "playbackDeepLinkUri"
// Gracenote输入类型的ID
private const val GRACENOTE_ID = "gracenote_ontv" // gracenote ontv id
private const val GRACENOTE_GVD = "gracenote_gvd" // gracenote gvd id



class SetupActivity : Activity() {
    private fun insertChannel(): Long? {
        // 播放深层链接URI的合约
        // 使用Intent.URI_INTENT_SCHEME从意图创建URI并转换回原始意图
        val playbackDeepLinkIntent = Intent() // 由您的应用创建
        val playbackDeepLinkUri = playbackDeepLinkIntent.toUri(Intent.URI_INTENT_SCHEME)

        val jsonString: String? = try {
             JSONObject()
                .put(EXTERNAL_ID_TYPE, "#Actual Id Type#") // 替换为GRACENOTE_XXX
                .put(
                    EXTERNAL_ID_VALUE,
                    "#Actual Id Value#"
                ) // 替换为与频道关联的gracenote ID值
                .put(PLAYBACK_DEEP_LINK_URI, playbackDeepLinkUri)
                .toString()
        } catch (e: JSONException) {
            Log.e(TAG, "Error when adding data to blob", e)
            null
        }

        // 构建BLOB
        val values = ContentValues().apply { //存储所有频道数据
            put(TvContract.Channels.COLUMN_DISPLAY_NAME, "#Actual display name#")
            put(TvContract.Channels.COLUMN_INPUT_ID, "#Actual input id#")
            if (jsonString != null) {
                put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA, jsonString.toByteArray())
            }
        }

        val uri = contentResolver.insert(TvContract.Channels.CONTENT_URI, values)

        Log.i("SetupActivity", "Channel Inserted! Uri:$uri")
        return uri?.lastPathSegment?.toLongOrNull()
    }
}

private val TAG = "MyTAG"

执行家长监护

以下代码演示了如何侦听针对实时预览或本机全屏播放的家长监护。

private TvContentRating mBlockedRating = null;

    @Override
    public boolean onTune(final Uri channelUri) {
        ...
        if (mTvInputManager.isParentalControlsEnabled()) {
            // 确保播放在Surface上无法听到或看见
            mBlockedRating = <content_rating>;
            // 1.在全屏播放时为用户触发PIN提示
            // 2.确保浏览“正在播放”行时节目画面
            // 不会跳转到播放Surface。
            notifyContentBlocked(mBlockedRating);
        } else {
            // 播放应开始
            notifyContentAllowed();
        }
        ...
    }

    @Override
    public void onUnblockContent(final TvContentRating unblockedRating) {
        // 用户成功输入PIN以解禁
        // 所选评级对应的内容
        if (unblockedRating.unblockContent(mBlockedRating)) {
            // 播放应开始
            notifyContentAllowed();
        }
    }
import android.content.Context
import android.media.tv.TvContentRating
import android.media.tv.TvInputManager
import android.media.tv.TvInputService
import android.net.Uri
import android.view.Surface

private const val TAG = "MyTag"

private class PreviewSession(context: Context) :
    TvInputService.Session(context) {

    private val tvInputManager: TvInputManager = TODO()



    override fun onTune(channelUri: Uri): Boolean {
        if (tvInputManager.isParentalControlsEnabled) {
            // 确保在Surface上无法听到或看到播放
            val blockedRating = getContentRating(channelUri)
            // 1.在全屏播放时为用户触发PIN提示
            // 2.确保浏览“正在播放”行时节目画面
            // 不会跳转到播放Surface。
            notifyContentBlocked(blockedRating)
        } else {
            // 播放应开始
            notifyContentAllowed()
        }
        return true
    }

    override fun onUnblockContent(unblockedRating: TvContentRating) {
        // 用户成功输入PIN以解禁
        // 适用于给定评级的内容
        if (unblockedRating.unblockContent(mBlockedRating)) { // <-- 这是什么?
            // 播放应开始
            notifyContentAllowed();
        }
    }
}

private fun getContentRating(channelUri: Uri): TvContentRating = TODO()

提供应用横幅

要在直播TV设置中显示应用横幅,须通过程序包管理器提供应用横幅。

// 在AndroidManifest.xml中
<application
    android:allowBackup="false"
    android:label="@string/app_name"
    android:banner="@drawable/app_icon_banner"
    tools:replace="android:allowBackup, allow:label, android:theme" >

    <meta-data
        android:name="****"
        android:value="true"
    />
</application>

要测试横幅,请参阅以下代码片段:

Drawable appDrawable = null;
try {
    String packageName = "****"; // replace **** with real package name
    PackageManager packageManager = getContext().getPackageManager();
    appDrawable = packageManager.getApplicationBanner(packageName);
} catch (PackageManager.NameNotFoundException e) {
    Log.i(TAG, "Can't find application banner for package : " + packageName);
}
val packageName = "****" //将****替换为程序包名称
val appDrawable: Drawable? = try {
    packageManager.getApplicationBanner(packageName)
} catch (e: PackageManager.NameNotFoundException) {
    Log.i("SetupActivity", "Can't find application banner for package : $packageName")
    null
}

示例直播TV应用

GitHub在github.com/amzn/ftv-livetv-sample-tv-app提供了一个具有直播TV集成的示例应用。这个示例电视应用是基于Google示例电视应用。您可以使用此示例应用作为Fire TV电视直播集成的参考。

直播应用的区域设置支持

仅以下区域设置支持示例应用: 美国、加拿大、英国、德国、日本、西班牙和印度。其他市场即将推出支持。

要加载示例应用,请执行以下操作:

  1. 转到https://github.com/amzn/ftv-livetv-sample-tv-app,单击Clone or download(克隆或下载),然后单击Download ZIP(下载ZIP)。解压下载文件。

    该应用显示了集成直播TV的示例代码。要查看结果,请使用ADB将app-debug.apk文件侧载到您的Fire TV上,如以下步骤所述。

  2. 通过ADB连接到Fire TV

    如果您已开启调试并安装了ADB,只需从Settings(设置)> Device & Software(设备和软件)(或My Fire TV [我的Fire TV]) > About(关于)> Network(网络)获取Fire TV的IP地址,然后运行以下内容,从而自定义您自己Fire TV的IP地址:

    adb connect 123.456.7.89:5555
    

    123.456.7.89替换为您的Fire TV的IP地址。(如果您在连接时遇到问题,并且您正在使用公司VPN,请尝试断开VPN连接,因为您的计算机需要与您的Fire TV处于同一个WiFi网络中。)

  3. 在示例应用中安装构建的APK:

    adb install -r AndroidTvSampleInput/app/build/outputs/apk/app-debug.apk
    

    响应如下:

    Performing Streamed Install
    
    Success
    

    请注意,此示例应用不会作为传统意义上的独立应用启动。相反,该应用包括了Fire TV设备提供的直播TV频道的代码。

  4. 在您的Fire TV设备上,前往Settings > Applications(应用)> Manage Installed Applications(管理已安装应用)。选择电视输入示例。然后单击启动应用

    电视输入示例
    电视输入示例

    这将带您进入亚马逊开发者门户。

    亚马逊Fire TV网站
    亚马逊Fire TV网站
  5. 在您的Fire TV遥控器上,单击主页按钮以退出此界面。然后前往Settings > Live TV(直播TV) > Sync Sources(同步源)> Amazon Sample TV Input(亚马逊电视输入示例)。

    这将加载示例频道。

    同步源
    同步源
  6. 同步完成后,单击主页按钮。频道现在应该会在“正在播放”行和指南中显示。

    这是“正在播放”行:

    Fire TV“正在播放”行
    Fire TV“正在播放”行

    这是频道指南。

    Fire TV频道指南
    Fire TV频道指南

    要导航到Fire TV上的频道指南,请转到主屏幕,向下滚动到“正在播放”行,按遥控器上的菜单按钮,然后点频道指南。您也可以按一下遥控器上的麦克风按钮,然后说“频道指南”。

自定义日志调试

如何查找所有现有频道

以下示例演示了如何在Android电视数据库中查询所有现有频道。

private static String[] CHANNEL_TABLE_PROJECTIONS = new String[] {
    TvContractCompat.Channels._ID,
        TvContractCompat.Channels.COLUMN_DESCRIPTION,
        TvContractCompat.Channels.COLUMN_DISPLAY_NAME,
        TvContractCompat.Channels.COLUMN_DISPLAY_NUMBER,
        TvContractCompat.Channels.COLUMN_INPUT_ID,
        TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_DATA,
        TvContractCompat.Channels.COLUMN_NETWORK_AFFILIATION,
        TvContractCompat.Channels.COLUMN_ORIGINAL_NETWORK_ID,
        TvContractCompat.Channels.COLUMN_PACKAGE_NAME,
        TvContractCompat.Channels.COLUMN_SEARCHABLE,
        TvContractCompat.Channels.COLUMN_SERVICE_ID,
        TvContractCompat.Channels.COLUMN_SERVICE_TYPE,
        TvContractCompat.Channels.COLUMN_TRANSPORT_STREAM_ID,
        TvContractCompat.Channels.COLUMN_TYPE,
        TvContractCompat.Channels.COLUMN_VIDEO_FORMAT,
        TvContractCompat.Channels.COLUMN_BROWSABLE,
        TvContractCompat.Channels.COLUMN_LOCKED,
        TvContractCompat.Channels.COLUMN_APP_LINK_COLOR,
        TvContractCompat.Channels.COLUMN_APP_LINK_ICON_URI,
        TvContractCompat.Channels.COLUMN_APP_LINK_INTENT_URI,
        TvContractCompat.Channels.COLUMN_APP_LINK_POSTER_ART_URI,
        TvContractCompat.Channels.COLUMN_APP_LINK_TEXT,
        TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1,
        TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_FLAG2,
        TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_FLAG3,
        TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_FLAG4
};

public List < Channel > getChannels(ContentResolver resolver) {
    Log.d(Utils.DEBUG_TAG, "Start testing query channel table for preview channels...");
    Cursor cursor = null;
    List < Channel > channels = new ArrayList < > ();
    try {
        cursor = resolver.query(TvContractCompat.Channels.CONTENT_URI, CHANNEL_TABLE_PROJECTIONS, null, null, null);
        if (cursor == null || cursor.getCount() == 0) {
            Log.d(TAG, "No channel inserted \n");
            return null;
        }
        while (cursor.moveToNext()) {
            Channel channel = Channel.fromCursor(cursor);
            channels.add(channel);
            Log.d(TAG, "Found channel " + channel);
        };
    } catch (Exception e) {
        Log.d(TAG, "Unable to get the channels " + e);
        return null;
    } finally {
        if (cursor != null) {
            cursor.close();

        }
    }
    return channels;
}
private val CHANNEL_TABLE_PROJECTIONS = arrayListOf(
    TvContractCompat.Channels._ID,
    TvContractCompat.Channels.COLUMN_DESCRIPTION,
    TvContractCompat.Channels.COLUMN_DISPLAY_NAME,
    TvContractCompat.Channels.COLUMN_DISPLAY_NUMBER,
    TvContractCompat.Channels.COLUMN_INPUT_ID,
    TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_DATA,
    TvContractCompat.Channels.COLUMN_NETWORK_AFFILIATION,
    TvContractCompat.Channels.COLUMN_ORIGINAL_NETWORK_ID,
    TvContractCompat.Channels.COLUMN_PACKAGE_NAME,
    TvContractCompat.Channels.COLUMN_SEARCHABLE,
    TvContractCompat.Channels.COLUMN_SERVICE_ID,
    TvContractCompat.Channels.COLUMN_SERVICE_TYPE,
    TvContractCompat.Channels.COLUMN_TRANSPORT_STREAM_ID,
    TvContractCompat.Channels.COLUMN_TYPE,
    TvContractCompat.Channels.COLUMN_VIDEO_FORMAT,
    TvContractCompat.Channels.COLUMN_BROWSABLE,
    TvContractCompat.Channels.COLUMN_LOCKED,
    TvContractCompat.Channels.COLUMN_APP_LINK_COLOR,
    TvContractCompat.Channels.COLUMN_APP_LINK_ICON_URI,
    TvContractCompat.Channels.COLUMN_APP_LINK_INTENT_URI,
    TvContractCompat.Channels.COLUMN_APP_LINK_POSTER_ART_URI,
    TvContractCompat.Channels.COLUMN_APP_LINK_TEXT,
    TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_FLAG1,
    TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_FLAG2,
    TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_FLAG3,
    TvContractCompat.Channels.COLUMN_INTERNAL_PROVIDER_FLAG4
)

class SetupActivity : Activity() {
   ...

    private fun getChannels(): List<Channel> {
        Log.d(TAG, "Start testing query channel table for preview channels...")
        return try {
            val cursor = contentResolver.query(
                TvContractCompat.Channels.CONTENT_URI,
                CHANNEL_TABLE_PROJECTIONS,
                null,
                null,
                null
            )

            cursor.use {
                if (cursor == null || cursor.count == 0) {
                    Log.d(TAG, "No channel inserted \n")
                    emptyList()
                } else {
                    buildList {
                        while (cursor.moveToNext()) {
                            val channel = Channel.fromCursor(cursor)
                            Log.d(TAG, "Found channel $channel")
                            add(channel)
                        }
                    }
                }
            }
        } catch (e: Exception) {
            Log.d(TAG, "Unable to get the channels", e)
            emptyList()
        }
    }
}

如何查找某个频道中所有的节目

以下是现有节目的查询方法。

代码示例: 查询属于特定频道的节目元数据的示例:

public static List < Program > getPrograms(ContentResolver resolver, Uri channelUri) {
    if (channelUri == null) {
        return null;
    }
    Uri uri = TvContract.buildProgramsUriForChannel(channelUri);
    List < Program > programs = new ArrayList < > ();
    // TvProvider在默认情况下按时间顺序返回节目。
    Cursor cursor = null;
    try {
        cursor = resolver.query(uri, Program.PROJECTION, null, null, null);
        if (cursor == null || cursor.getCount() == 0) {
            return programs;
        }
        while (cursor.moveToNext()) {
            programs.add(Program.fromCursor(cursor));
        }
    } catch (Exception e) {
        Log.w(TAG, "Unable to get programs for " + channelUri, e);
    } finally {
        if (cursor != null) {
            cursor.close();
        }
    }
    return programs;
}
fun getPrograms(resolver: ContentResolver, channelUri: Uri): List<Program> {
    val uri = TvContract.buildProgramsUriForChannel(channelUri)

    return try {
        //TvProvider在默认情况下按时间顺序返回节目。
        val cursor = resolver.query(uri, null, null, null, null) // <- Program.PROJECT导致错误,空值意味着全部。
        cursor.use {
            if (cursor == null || cursor.count == 0) {
                emptyList()
            } else {
                buildList {
                    while (cursor.moveToNext()) {
                        add(Program.fromCursor(cursor))
                    }
                }
            }
        }
    } catch (e: Exception) {
        Log.w(TAG, "Unable to get programs for $channelUri", e)
        emptyList()
    }
}

Last updated: 2022年10月10日