概述
上一篇文章Android Tv Input Framwork翻译了Google对TIF的介绍,但对刚接触TV开发的小伙伴来说,仅仅概念的陈述很难让人理解,加上缺少简洁明了的示例分析,看了之后可能还是一头雾水,实际开发中不知从何下手。本文旨在以示例剖析帮助初学者入门。
组成
在认识TIF之前,我们应了解TIF的组成部分
- TV应用(我们开发的应用)
- TV Provider应用(包名为com.android.providers.tv的应。存储了频道、节目等信息的内容提供者应用)
- TV Input应用(信号源应用。信号源可以是数字信号、模拟信号、网络信号)
- TV Input HAL (TV Input的硬件抽象层,可以让TV Input应用访问TV特有硬件)
- TVInputManager(使TV应用能和TV Input应用进行通讯。实际上是让我们的TV应用能访问到TV Input的一个系统服务类)
- Parental Control(儿童锁。包括家长锁总开关和节目内容年龄限制,类似于权限控制)
- HDMI-CEC(HDMI的远程控制技术)
控制流程
我们的TV应用是如何显示信号源的呢?
- 用户通过TV应用点击搜台按钮(搜台页面一般是TV Input应用的Activity,TV应用通过action隐式启动)
- TV Input应用调用厂商及硬件提供的中间件接口获取数据源并将频道及节目数据写入TV Provider应用(卫星信号调制-解调-解扰-解码-纠错-解交织转成码流的工作由设备厂商及硬件开发处理,TV Input应用开发者只需关注接口调用即可)
- 当数据完全写入TV Provider应用后完成搜台,TV应用从TV Provider拉取频道及节目数据进行UI展示
- 用户通过TV应用切台(切换频道)时,TV应用通过TVInputManager获取信号源
- TV应用通过TIF播放该频道节目
以下为tif控制流程:
上图描述了搜台和切台的两个过程:
- 当用户搜台时,tv input应用将搜到的频道和节目信息(可以是通过hdmi或tuner口、网络、机顶盒传输)写入tv provider,我们的tv应用从tv provider取出这些数据展示给用户
- 当用户切台时,我们的tv应用调用tvView的tune(inputId, channelUri)方法,tv input manager根据inputId检测到注册的tv input服务,并回调到相应的TvInputService.Session.onTune(channelUri)方法,在onTune(channelUri)方法中根据channelUri从tv provider查询到channel信息解析播放并回传给tv应用的tvView展示给用户
切台详细流程:
代码剖析
以我写的TIF演示代码为依托,讲解tif代码的调用过程
代码结构
主体分为两个部分,app应用和tifservice子模块。此处app应用相当于上述描述的 TV应用 ,tifservice子模块相当于上述描述的 TV Input应用 。为了方便查阅,我将两份代码合在一起,app应用在启动时通过action拉起子进程tifservice,实际开发中应该作为两个应用单独开发。
app应用只有一个主页面,页面中包含一个tvview用于播放节目,还包含一个搜台按钮和一个切台按钮
tifservice子模块中包含一个搜台页面、TvInputService类(和app应用的tvView进行交互)、tv input包(获取数据源并解析)
代码剖析
在阅读以下内容前,务必先了解上述控制流程。以下只对关键代码剖析,完整代码请参阅 参考资料
-
子进程注册TvInputService,使系统服务TvInputManager能检测到该路信号源
AndroidManifest.xml
<!-- android:process属性指定为子进程 --> <!-- android:permission="android.permission.BIND_TV_INPUT"允许该服务将TV Input连接到系统 --> <service android:name=".TifService" android:process=":tif" android:enabled="true" android:exported="true" android:permission="android.permission.BIND_TV_INPUT"> <intent-filter> <!-- 以便系统能检测我们的input服务 --> <action android:name="android.media.tv.TvInputService" /> <!-- 以便外部通过action拉起该服务 --> <action android:name="com.cyb.action.TV_SERVICE" /> </intent-filter> <meta-data android:name="android.media.tv.input" android:resource="@xml/tvinputservice" /> </service>
tvinputservice.xml
<?xml version="1.0" encoding="utf-8"?> <!-- android:canRecord指定该tv input是否支持录制 --> <!-- android:setupActivity指定ScanActivity供外部通过TvInputInfo.createSetupIntent()启动 --> <tv-input xmlns:android="http://schemas.android.com/apk/res/android" android:canRecord="true" android:setupActivity="com.cyb.tifservice.ScanActivity" />
-
子进程重写TvInputService的onCreateSession()方法
package com.cyb.tifservice; import android.content.Context; import android.media.MediaPlayer; import android.media.tv.TvInputManager; import android.media.tv.TvInputService; import android.net.Uri; import android.os.Build; import android.view.Surface; import android.view.View; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.tvprovider.media.tv.Channel; import java.io.IOException; /** * author: caoyb * created on: 2022/11/17 10:04 * description: * 这里只负责inputId为xxx的session及recordingSession的创建 * 1.Session 主要负责将tvView中的信息回调过来(包括显示用的surface、channel信息),在这里可以过滤节目内容分级、控制节目播放、控制timeShift等操作 * 2.RecordingSession 主要负责录制功能 */ public class TifService extends TvInputService { @Nullable @Override public Session onCreateSession(@NonNull String inputId) { return new InputSession(this); } @Nullable @Override @RequiresApi(api = Build.VERSION_CODES.N) public RecordingSession onCreateRecordingSession(@NonNull String inputId) { return new RecordSession(this); } public static class InputSession extends Session { private final Context context; private final MediaPlayer mediaPlayer; private TvInputManager tvInputManager; /** * Creates a new Session. * * @param context The context of the application */ public InputSession(Context context) { super(context); this.context = context; // 初始胡tvInputManager tvInputManager = (TvInputManager)context.getSystemService(TV_INPUT_SERVICE); // 初始化播放器 mediaPlayer = new MediaPlayer(); } @Override public void onRelease() { // tvView资源释放回调,播放器释放 mediaPlayer.release(); } @Override public boolean onSetSurface(@Nullable Surface surface) { // tvView中的surface回调,将surface设置给播放器 mediaPlayer.setSurface(surface); return false; } @Override public void onSetStreamVolume(float volume) { // 控制音量回调,将音量设置给播放器 mediaPlayer.setVolume(volume, volume); // 参数分别为左声道音量和右声道音量 } @Override public boolean onTune(Uri channelUri) { // 1.根据channelUri查询channel表 // 2.取出播放地址字段(根据信号源插入tv.db时存放播放地址的字段) // 3.在这里使用播放器播放内容源(一般是根据channel找到当前时间正在播放的program) // 切台播放program时 或 接收家长控制广播时 可做家长控制及内容分级判断 // tvInputManager.isParentalControlsEnabled() 是否家长控制 // tvInputManager.isRatingBlocked(program.getContentRatings[x]()) 是否类容分级被禁 // 确定是否禁播调用 notifyContentAllowed()或notifyContentBlocked(currentContentRating)通知TvView的onContentAllowed() 或 onContentBlocked()回调 // 一般而言,在切换频道时,为了避免闪屏,先调用notifyVideoUnavailable()关闭画面, 等内容呈现在surface时再调用notifyVideoAvailable() try { Channel channel = TvDBUtil.getChannel(context, channelUri); if (channel == null) { return false; } String playUrl = new String(channel.getInternalProviderDataByteArray()); // 假设将信号源插入tv.db时播放地址存于“internal_provider_data”,取播放地址时也从“internal_provider_data”取字段 mediaPlayer.setDataSource(playUrl); mediaPlayer.prepare(); mediaPlayer.start(); return true; } catch (IOException e) { e.printStackTrace(); } return false; } @Override public void onSetCaptionEnabled(boolean enabled) { // 控制字幕 } @Override public View onCreateOverlayView() { // 创建叠加层 return super.onCreateOverlayView(); } @Override public boolean onSelectTrack(int type, @Nullable String trackId) { // 切换轨道(音轨、视频轨道、字幕轨道)回调 for (int i = 0; i < mediaPlayer.getTrackInfo().length; i++) { if (mediaPlayer.getTrackInfo()[i].getTrackType() == type) { mediaPlayer.selectTrack(i); notifyTrackSelected(type, trackId); } } return super.onSelectTrack(type, trackId); } } @RequiresApi(api = Build.VERSION_CODES.N) public static class RecordSession extends RecordingSession { /** * Creates a new RecordingSession. * * @param context The context of the application */ public RecordSession(Context context) { super(context); } @Override public void onTune(Uri channelUri) { } @Override public void onStartRecording(@Nullable Uri programUri) { } @Override public void onStopRecording() { } @Override public void onRelease() { } } }
-
子进程ScanActivity实现搜台功能(获取数据源并写入TV Provider,此处仅实现网络获取方式)
package com.cyb.tifservice; import androidx.appcompat.app.AppCompatActivity; import android.content.ContentValues; import android.content.Context; import android.media.tv.TvContract; import android.os.Bundle; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.Toast; import com.cyb.tifservice.input.iptv.api.VODApi; import com.cyb.tifservice.input.iptv.api.VODService; import com.cyb.tifservice.input.iptv.response.VODResult; import java.util.List; import rx.android.schedulers.AndroidSchedulers; import rx.functions.Action1; import rx.functions.Func1; import rx.schedulers.Schedulers; /** * 简易搜台页面 * * 负责将输入的信号源写入tv.db(也可以是写入其他db) */ public class ScanActivity extends AppCompatActivity { private final String TAG = ScanActivity.class.getSimpleName(); private Button btnIp; private Button btnDtv; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_scan); initView(); setListener(); } private void initView() { btnIp = findViewById(R.id.btn_ip); btnDtv = findViewById(R.id.btn_dtv); } private void setListener() { btnIp.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { addVOD(); } }); btnDtv.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { } }); } /** * 请求vod接口并写入tv.db */ private void addVOD() { VODApi vodApi = VODApi.getInstance(); VODService vodService = vodApi.getVODService(); vodService.getResult() .subscribeOn(Schedulers.newThread()) .map(new Func1<VODResult, VODResult>() { @Override public VODResult call(VODResult vodResult) { List<VODResult.GooglevideosBean> googlevideos = vodResult.getGooglevideos(); for (VODResult.GooglevideosBean googlevideosBean : googlevideos) { for (VODResult.GooglevideosBean.VideosBean videoBean : googlevideosBean.getVideos()) { Log.i(TAG, "videoBean: " + videoBean.toString()); // 将请求到的vod数据写入tv.db channel表() insertVOD2TvChannel(ScanActivity.this, videoBean); } } return vodResult; } }).subscribeOn(Schedulers.newThread()) .observeOn(AndroidSchedulers.mainThread()) .subscribe(new Action1<VODResult>() { @Override public void call(VODResult s) { Toast.makeText(ScanActivity.this,"数据写入完毕",Toast.LENGTH_SHORT).show(); } }, new Action1<Throwable>() { @Override public void call(Throwable throwable) { throwable.printStackTrace(); } }); } /** * 将vod数据插入tv.db的channel表 */ private void insertVOD2TvChannel(Context context, VODResult.GooglevideosBean.VideosBean videoBean) { ContentValues value = new ContentValues(); value.put(TvContract.Channels.COLUMN_INPUT_ID, "com.cyb.tifservice/.TvService"); // inputId字段(用于标识一路信号源) value.put(TvContract.Channels.COLUMN_INTERNAL_PROVIDER_DATA, videoBean.getSources().get(0)); //url value.put(TvContract.Channels.COLUMN_DISPLAY_NAME, videoBean.getTitle()); //name value.put(TvContract.Channels.COLUMN_DESCRIPTION, videoBean.getDescription()); //description // ... // 根据需要将其他可能用到的信息存入tv.db channel表的其他字段 context.getContentResolver().insert(TvContract.Channels.CONTENT_URI, value); } @Override protected void onDestroy() { super.onDestroy(); btnIp.setOnClickListener(null); btnDtv.setOnClickListener(null); } }
-
主应用实现点击事件
package com.cyb.live; import static com.cyb.live.LiveConstant.TIF_SCAN_VIEW_ACTION; import static com.cyb.live.LiveConstant.TIF_SERVICE_ACTION; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import androidx.appcompat.app.AppCompatActivity; import androidx.tvprovider.media.tv.Channel; import android.content.Context; import android.content.Intent; import android.media.tv.TvContentRating; import android.media.tv.TvContract; import android.media.tv.TvInputInfo; import android.media.tv.TvInputManager; import android.media.tv.TvRecordingClient; import android.media.tv.TvTrackInfo; import android.media.tv.TvView; import android.net.Uri; import android.os.Build; import android.os.Bundle; import android.os.Handler; import android.util.Log; import android.view.View; import android.widget.Button; import android.widget.Toast; import com.cyb.tifservice.ScanActivity; import com.cyb.tifservice.TvDBUtil; import com.cyb.tifservice.recording.RecordingManager; import java.util.List; @RequiresApi(api = Build.VERSION_CODES.N) public class MainActivity extends AppCompatActivity { private String TAG = MainActivity.class.getSimpleName(); private TvView tvView; private Button btnScan; private Button btnTune; private Button btnRecoding; private TvRecordingClient tvRecordingClient; // 录制客户端 private Handler handler; private List<Channel> channelsList; private int index = 0; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); handler = new Handler(); tvRecordingClient = RecordingManager.getSingleton().getClient(this, "recording-tag", recordingCallback, handler); setContentView(R.layout.activity_main); initView(); setListener(); } @Override protected void onStart() { super.onStart(); initData(); } private void initView() { tvView = findViewById(R.id.tv_view); btnScan = findViewById(R.id.btn_scan); btnTune = findViewById(R.id.btn_tune); btnRecoding = findViewById(R.id.btn_recoding); } private void initData() { channelsList = TvDBUtil.getChannelList(this); } private void setListener() { btnScan.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { //方式一:利用清单文件tvInputService声明setupActivity TvInputManager inputManager = (TvInputManager) getSystemService(Context.TV_INPUT_SERVICE); for (TvInputInfo info: inputManager.getTvInputList()) { Log.i(TAG, "inputId: " + info.getId()); if (info.getId().equals("com.cyb.live/com.cyb.tifservice.TifService")) { Intent intent = info.createSetupIntent(); startActivity(intent); } } // // 方式二:利用action跳转,需在清单文件<intent-filter>中注册该action // Intent intent = new Intent(TIF_SCAN_VIEW_ACTION); // intent.setPackage(getPackageName()); // startActivity(intent); } }); btnTune.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { if (channelsList == null) { Toast.makeText(MainActivity.this,"channel is loading...",Toast.LENGTH_SHORT).show(); return; } TvInputManager inputManager = (TvInputManager) getSystemService(Context.TV_INPUT_SERVICE); for (TvInputInfo info: inputManager.getTvInputList()) { Log.i(TAG, "inputId: " + info.getId()); if (info.getId().equals("com.cyb.live/com.cyb.tifservice.TifService")) { String inputId = info.getId(); // 信号输入源Id Uri channelUri = TvContract.buildChannelUri(channelsList.get(index).getId()); // 频道Uri Log.i(TAG, "inputId: " + inputId + " channelUri: " + channelUri); tvView.reset(); tvView.tune(inputId, channelUri); // 切频道改变index if (index >= channelsList.size() - 1) { index = 0; } else { index++; } return; } } } }); btnRecoding.setOnClickListener(new View.OnClickListener() { @Override public void onClick(View v) { // 伪代码 录制节目前先调谐到该节目所在频道 - 在recordingCallback的onTuned()回调中启动录制 String inputId = "com.cyb.live/com.cyb.tifservice.TifService"; Uri channelUri = TvContract.buildChannelUri(1); tvRecordingClient.tune(inputId, channelUri); } }); tvView.setCallback(tvInputCallback); } /** * input回调 */ TvView.TvInputCallback tvInputCallback = new TvView.TvInputCallback() { @Override public void onConnectionFailed(String inputId) { super.onConnectionFailed(inputId); } @Override public void onDisconnected(String inputId) { super.onDisconnected(inputId); } @Override public void onChannelRetuned(String inputId, Uri channelUri) { super.onChannelRetuned(inputId, channelUri); } @Override public void onTracksChanged(String inputId, List<TvTrackInfo> tracks) { // session 调用 notifyTracksChanged()时回调 super.onTracksChanged(inputId, tracks); } @Override public void onTrackSelected(String inputId, int type, String trackId) { // session 调用 notifyTrackSelected()时回调 super.onTrackSelected(inputId, type, trackId); } @Override public void onVideoSizeChanged(String inputId, int width, int height) { super.onVideoSizeChanged(inputId, width, height); } @Override public void onVideoAvailable(String inputId) { // session 调用 notifyVideoAvailable()时回调 super.onVideoAvailable(inputId); } @Override public void onVideoUnavailable(String inputId, int reason) { // session 调用 notifyVideoUnavailable()时回调 super.onVideoUnavailable(inputId, reason); } @Override public void onContentAllowed(String inputId) { // session 调用 notifyContentAllowed()时回调 super.onContentAllowed(inputId); } @Override public void onContentBlocked(String inputId, TvContentRating rating) { // session 调用 notifyContentBlocked(currentContentRating)时回调 super.onContentBlocked(inputId, rating); } @Override public void onTimeShiftStatusChanged(String inputId, int status) { // session 调用 notifyTimeShiftStatusChanged(int)时回调 super.onTimeShiftStatusChanged(inputId, status); } }; /** * 录制回调 */ TvRecordingClient.RecordingCallback recordingCallback = new TvRecordingClient.RecordingCallback() { @Override public void onConnectionFailed(String inputId) { Log.i(TAG, "onConnectionFailed"); super.onConnectionFailed(inputId); } @Override public void onDisconnected(String inputId) { Log.i(TAG, "onDisconnected"); super.onDisconnected(inputId); } @Override public void onTuned(Uri channelUri) { Log.i(TAG, "onTuned"); // 伪代码 调谐后启动录制 Uri programUri = TvContract.buildProgramUri(1); tvRecordingClient.startRecording(programUri); super.onTuned(channelUri); } @Override public void onRecordingStopped(Uri recordedProgramUri) { Log.i(TAG, "onRecordingStopped"); super.onRecordingStopped(recordedProgramUri); } @Override public void onError(int error) { Log.i(TAG, "onError"); super.onError(error); } }; @Override protected void onDestroy() { super.onDestroy(); btnScan.setOnClickListener(null); btnTune.setOnClickListener(null); btnRecoding.setOnClickListener(null); } }
访问Channel、Program类需要导包
implementation "androidx.tvprovider:tvprovider:1.0.0"
其他功能
- timeshift功能(边录边播)
- 录制功能