如何在Android TV 桌面添加自定义频道/节目

最近在做Android TV O的项目,需要在TV 桌面添加自定义频道/节目,节目的背景图片要显示为SD卡或者缓存目录里面的图片。

  1. 添加自定义频道
  2. 节目背景显示本地目录的图片

一、添加频道

1. 首先新建频道、节目实体类,属性如下。

public class MediaChannel {
    private final String mName;
    private final String mDescription;
    private final String mMediaUri;
    private final String mBgImage;
    private final String mTitle;
    private final String mMediaChannelId;
    private List<MediaProgram> mPrograms;
    private boolean mChannelPublished;
    private long mChannelId;

    MediaChannel(String name, List<MediaProgram> programs, String mediaChannelId) {
        mName = name;
        mTitle = "playlist title";
        mDescription = "playlist description";
        mMediaUri = "dsf";
        mBgImage = "asdf";
        mPrograms = programs;
        mMediaChannelId = mediaChannelId;
    }
    // 省略 set get toString 
 }

public class MediaProgram implements Parcelable {
    private final String mMediaProgramId;
    private final String mContentId;
    private final String mTitle;
    private final String mDescription;
    private final String mBgImageUrl;
    private final String mCardImageUrl;
    private final String mMediaUrl;
    private final String mPreviewMediaUrl;
    private final String mCategory;
    private long mProgramId;
    private int mViewCount;

    MediaProgram(String title, String description, String bgImageUrl, String cardImageUrl,
                 String category, String mediaProgramId, String contentId) {
        mMediaProgramId = mediaProgramId;
        mContentId = contentId;
        mTitle = title;
        mDescription = description;
        mBgImageUrl = bgImageUrl;
        mCardImageUrl = cardImageUrl;
        mMediaUrl = "";
        mPreviewMediaUrl = "";
        mCategory = category;
    }

    // 省略 set get toString 

}

2. 初始化频道、节目信息

private void initChannel() {
        Uri usbUri = getUSBCardImageFileUri();
        Uri pvrUri = getPVRCardImageFileUri();
        grantUriPermissionToApp("com.google.android.tvlauncher", usbUri);
        grantUriPermissionToApp("com.google.android.tvlauncher", pvrUri);

        String bgImageUrl = "";
        String usbCardImageUrl = getUSBCardImageFileUri().toString();
        String pvrCardImageUrl = getPVRCardImageFileUri().toString();
        int mediaProgramId = 1;
        int contentId = 0;

        MediaProgram usbProgram = new MediaProgram("USB", "usb description", bgImageUrl, usbCardImageUrl,
                "USB category", Integer.toString(mediaProgramId), Integer.toString(contentId ++));

        MediaProgram pvrProgram = new MediaProgram("PVR", "pvr description", bgImageUrl, pvrCardImageUrl,
                "PVR category", Integer.toString(mediaProgramId), Integer.toString(contentId ++));
        List<MediaProgram> programs = new ArrayList<>();
        programs.add(usbProgram);
        programs.add(pvrProgram);

        mChannelId = LocalDataManager.getChannelId(this);
        mChannel = new MediaChannel("MediaChannel", programs, Long.toString(mChannelId));
}

    private Uri getUSBCardImageFileUri() {
        String sdPath = Environment.getExternalStorageDirectory().getPath();
        File file = new File(sdPath + "/Pictures/mediachannel/usb_thumbnail.jpg");
        Uri uri = FileProvider.getUriForFile(this, "com.rogera.mediaplaychannel.fileprovider", file);
        Log.v(TAG, "uri:" + uri.toString());
        return uri;
    }

    private Uri getPVRCardImageFileUri() {
        String sdPath = Environment.getExternalStorageDirectory().getPath();
        File file = new File(sdPath + "/Pictures/mediachannel/pvr_thumbnail.jpg");
        Uri uri = FileProvider.getUriForFile(this, "com.rogera.mediaplaychannel.fileprovider", file);
        Log.v(TAG, "uri:" + uri.toString());
        return uri;
    }

    private void grantUriPermissionToApp(String packageName, Uri uri) {
        grantUriPermission(packageName, uri, Intent.FLAG_GRANT_READ_URI_PERMISSION
                | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
    }

3.添加频道、节目

mChannelId = MediaTVProvider.addChannel(MainActivity.this, mChannel);

mChannel 为initChannel() 方法里面初始化的实体类

贴出核心类MediaTVProvider.java

package com.rogera.mediaplaychannel;

import android.content.ComponentName;
import android.content.ContentUris;
import android.content.Context;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.graphics.BitmapFactory;
import android.net.Uri;
import android.support.annotation.DrawableRes;
import android.support.annotation.WorkerThread;
import android.support.media.tv.Channel;
import android.support.media.tv.ChannelLogoUtils;
import android.support.media.tv.PreviewProgram;
import android.support.media.tv.TvContractCompat;
import android.text.TextUtils;
import android.util.Log;

import java.util.List;


/**
 * Created by rogera on 2017/12/30.
 */
public class MediaTVProvider {

    private static final String TAG = "MediaTVProvider";

    private static final String SCHEME = "tvmediachannels";
    private static final String APPS_LAUNCH_HOST = "com.google.android.tvmediachannels";
    private static final String PLAY_MEDIA_ACTION_PATH = "playMedia";
    private static final String START_APP_ACTION_PATH = "startApp";

    private static final Uri PREVIEW_PROGRAMS_CONTENT_URI =
            Uri.parse("content://android.media.tv/preview_program");

    static private String createInputId(Context context) {
        ComponentName cName = new ComponentName(context, MainActivity.class.getName());
        return TvContractCompat.buildInputId(cName);
    }

    @WorkerThread
    static long addChannel(Context context, MediaChannel mediaChannel) {
        String channelInputId = createInputId(context);
        Channel channel = new Channel.Builder()
                .setDisplayName(mediaChannel.getName())
                .setDescription(mediaChannel.getDescription())
                .setType(TvContractCompat.Channels.TYPE_PREVIEW)
                .setInputId(channelInputId)
                .setAppLinkIntentUri(Uri.parse(SCHEME + "://" + APPS_LAUNCH_HOST
                        + "/" + START_APP_ACTION_PATH))
                .setInternalProviderId(mediaChannel.getMediaChannelId())
                .build();

        Uri channelUri = context.getContentResolver().insert(TvContractCompat.Channels.CONTENT_URI,
                channel.toContentValues());
        if (channelUri == null || channelUri.equals(Uri.EMPTY)) {
            Log.e(TAG, "addChannel Insert channel failed");
            return 0;
        }
        long channelId = ContentUris.parseId(channelUri);
        mediaChannel.setChannelPublishedId(channelId);

        writeChannelLogo(context, channelId, R.drawable.media_logo);

        List<MediaProgram> programs = mediaChannel.getMediaPrograms();

        int weight = programs.size();
        for (int i = 0; i < programs.size(); ++i, --weight) {
            MediaProgram mp = programs.get(i);
            final String mediaProgramId = mp.getMediaProgramId();
            final String contentId = mp.getContentId();

            PreviewProgram program = new PreviewProgram.Builder()
                    .setChannelId(channelId)
                    .setTitle(mp.getTitle())
                    .setDescription(mp.getDescription())
                    .setPosterArtUri(Uri.parse(mp.getCardImageUrl()))
                    .setIntentUri(Uri.parse(SCHEME + "://" + APPS_LAUNCH_HOST
                            + "/" + PLAY_MEDIA_ACTION_PATH + "/" + mediaProgramId))
                    //.setPreviewVideoUri(Uri.parse(mp.getPreviewMediaUrl()))
                    .setInternalProviderId(mediaProgramId)
                    .setContentId(contentId)
                    .setWeight(weight)
                    .setType(TvContractCompat.PreviewPrograms.TYPE_CLIP)
                    .build();

            Uri programUri = context.getContentResolver().insert(PREVIEW_PROGRAMS_CONTENT_URI,
                    program.toContentValues());
            if (programUri == null || programUri.equals(Uri.EMPTY)) {
                Log.e(TAG, "addChannel Insert program failed");
            } else {
                mp.setProgramId(ContentUris.parseId(programUri));
            }
        }
        return channelId;
    }

    @WorkerThread
    static void deleteChannel(Context context, long channelId) {
        int rowsDeleted = context.getContentResolver().delete(
                TvContractCompat.buildChannelUri(channelId), null, null);
        if (rowsDeleted < 1) {
            Log.e(TAG, "Delete channel failed");
        }
    }

    @WorkerThread
    public static void deleteProgram(Context context, MediaProgram program) {
        deleteProgram(context, program.getProgramId());
    }

    @WorkerThread
    static void deleteProgram(Context context, long programId) {
        int rowsDeleted = context.getContentResolver().delete(
                TvContractCompat.buildPreviewProgramUri(programId), null, null);
        if (rowsDeleted < 1) {
            Log.e(TAG, "Delete program failed");
        }
    }

    /**
     * Writes a drawable as the channel logo.
     *
     * @param channelId  identifies the channel to write the logo.
     * @param drawableId resource to write as the channel logo. This must be a bitmap and not, say
     *                   a vector drawable.
     */
    @WorkerThread
    static private void writeChannelLogo(Context context, long channelId,
                                         @DrawableRes int drawableId) {
        Bitmap bitmap = BitmapFactory.decodeResource(context.getResources(), drawableId);
        ChannelLogoUtils.storeChannelLogo(context, channelId, bitmap);
    }

    @WorkerThread
    static void updateMediaProgram(Context context, MediaProgram mediaProgram) {
        long programId = mediaProgram.getProgramId();
        Uri programUri = TvContractCompat.buildPreviewProgramUri(programId);
        try (Cursor cursor = context.getContentResolver().query(programUri, null, null, null,
                null)) {
            if (!cursor.moveToFirst()) {
                Log.e(TAG, "Update program failed");
            }
            PreviewProgram porgram = PreviewProgram.fromCursor(cursor);
            PreviewProgram.Builder builder = new PreviewProgram.Builder(porgram)
                    .setTitle(mediaProgram.getTitle());

            int rowsUpdated = context.getContentResolver().update(programUri,
                    builder.build().toContentValues(), null, null);
            if (rowsUpdated < 1) {
                Log.e(TAG, "Update program failed");
            }
        }
    }

    static void publishProgram(Context context, MediaProgram mediaProgram, long channelId, int weight) {
        final String mediaProgramId = mediaProgram.getMediaProgramId();

        PreviewProgram program = new PreviewProgram.Builder()
                .setChannelId(channelId)
                .setTitle(mediaProgram.getTitle())
                .setDescription(mediaProgram.getDescription())
                .setPosterArtUri(Uri.parse(mediaProgram.getCardImageUrl()))
                .setIntentUri(Uri.parse(SCHEME + "://" + APPS_LAUNCH_HOST
                        + "/" + PLAY_MEDIA_ACTION_PATH + "/" + mediaProgramId))
                .setPreviewVideoUri(Uri.parse(mediaProgram.getPreviewMediaUrl()))
                .setInternalProviderId(mediaProgramId)
                .setWeight(weight)
                .setType(TvContractCompat.PreviewPrograms.TYPE_MOVIE)
                .build();

        Uri programUri = context.getContentResolver().insert(PREVIEW_PROGRAMS_CONTENT_URI,
                program.toContentValues());
        if (programUri == null || programUri.equals(Uri.EMPTY)) {
            Log.e(TAG, "Insert program failed");
            return;
        }
        mediaProgram.setProgramId(ContentUris.parseId(programUri));
    }

    @WorkerThread
    static void setProgramViewCount(Context context, long programId, int numberOfViews) {
        Uri programUri = TvContractCompat.buildPreviewProgramUri(programId);
        try (Cursor cursor = context.getContentResolver().query(programUri, null, null, null,
                null)) {
            if (!cursor.moveToFirst()) {
                return;
            }
            PreviewProgram existingProgram = PreviewProgram.fromCursor(cursor);
            PreviewProgram.Builder builder = new PreviewProgram.Builder(existingProgram)
                    .setInteractionCount(numberOfViews)
                    .setInteractionType(TvContractCompat.PreviewProgramColumns
                            .INTERACTION_TYPE_VIEWS);
            int rowsUpdated = context.getContentResolver().update(
                    TvContractCompat.buildPreviewProgramUri(programId),
                    builder.build().toContentValues(), null, null);
            if (rowsUpdated != 1) {
                Log.e(TAG, "Update program failed");
            }
        }
    }
}

二、节目背景显示本地目录的图片

对于显示本地图片,需要使用FileProvider 获取图片文件的uri然后设置给节目。如果使用 Uri.fromFile(new File(filePath) 这种方式,就会报Permission问题:
class java.io.FileNotFoundException: /storage/emulated/0/Pictures/mediachannel/usb_thumbnail.jpg (Permission denied)

使用FileProvider分享文件给其他应用需要给对应的应用赋予读权限,可以通过如下两种方式:

 intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
 grantUriPermissionToApp("com.google.android.tvlauncher", usbUri);

这里只能使用第二种方式了。com.google.android.tvlauncher 为TV launcher的包名。

1. 使用FileProvider首先需要在AndroidManifest.xml <application>节点下申明

    <provider
        android:name="android.support.v4.content.FileProvider"
        android:authorities="com.rogera.mediaplaychannel.fileprovider"
        android:grantUriPermissions="true"
        android:exported="false">
        <meta-data
            android:name="android.support.FILE_PROVIDER_PATHS"
            android:resource="@xml/filepaths" />
    </provider>

2. 在res下xml文件夹下新建filepaths.xml

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path path="Pictures" name="pictures" />
</paths>

3. 获取文件URI

File file = new File(sdPath + "/Pictures/mediachannel/usb_thumbnail.jpg");
Uri uri = FileProvider.getUriForFile(this, "com.rogera.mediaplaychannel.fileprovider", file);

三、其他说明

1、sdcard里面的文件是push进去的,是假设应用获取U盘里面的电影/图片/音乐 生成的缩略图。点击桌面的usb节目就会播放相应的电影/图片/音乐。

0.png

2. 在桌面添加频道、节目需要申请EPG权限,SD需要申请storage权限

<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA" />
<uses-permission android:name="com.android.providers.tv.permission.READ_EPG_DATA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

3. 同时需要在gradle添加如下依赖

implementation 'com.android.support:leanback-v17:26.1.0'
implementation 'com.android.support:support-tv-provider:26.1.0'

4. 上图啦

1.png
2.png

Google官方参考:https://developer.android.google.cn/training/tv/tif/channel.html#update

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容