直播界面适配平板大屏幕
MoMo Lv5

概述

内容

直播界面适配平板大屏幕(车机屏幕)

时间

预计 2/15~2/17 + 2/20

内容拆分

  1. 检查登录页是否有显示异常(布局重叠)
  2. 新增页面
    1. 设备列表Activity(仅横屏,复用设备卡片网格布局,点击进入直播查看Activity)
    2. 直播查看Activity(仅横屏,只保留看直播视频功能)
  3. 临时修改跳转:启动APP直接进入新增的设备列表页
  4. 功能对接
    1. 参考现有的直播页,利用已有的代码实现
    2. 列表页点击卡片跳转设备直播页
    3. 进入直播页面就加载直播视频
    4. 加载失败的时候展示重试按钮(状态)
    5. 对接现有的播放器手势操作:滑动到边缘控制云台

参考

  1. 强制Activity横屏或竖屏:https://blog.csdn.net/Smile_Qian/article/details/99187728

    1. requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_USER_PORTRAIT
  2. 卡片网格布局:https://www.cnblogs.com/guanxinjing/p/13037271.html

  3. 界面跳转封装:com.danale.edge.base.BaseNavigation

  4. 当前摄像头直播界面:com.danale.edge.ui.devicecontrol.ipc.IPCLiveFragment

  5. 视频渲染View手势处理封装类:com.danale.edge.ui.common.view.ScaleTextureView

  6. 设置点击图标拉起的启动Activity:

    1
    2
    <action android:name="android.intent.action.MAIN" /> 
    <category android:name="android.intent.category.LAUNCHER" />

测试账号

15105982803

App_2021

过程

大概的逻辑

  • 主页面

    • LandScapeListActivity作为主页面,在里面放置activity_land_scape_list设备列表

    • activity_land_scape_list中绑定对应的LandScapeListViewModel

    • LandScapeListViewModel中设置onclick函数,通过路由跳转到直播页面

  • 直播页

    • LandScapeIPCActivity作为直播的主页面,内部放置DeviceListToIPCFragment容器

主页

由于主页是通过Fragment切换动态创建的,所以首先创建一个新的Fragment放置设备列表

LandScapeListActivity.kt

创建主页的activity,绑定对应的xml布局文件

ui/devicelist/landscape/LandScapeListActivity.kt

1
2
3
4
5
6
7
8
9
10
11
12
package com.danale.edge.ui.devicelist.landscape

import android.os.Bundle
import com.danale.edge.R
import com.danale.edge.base.BaseActivity

class LandScapeListActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_land_scape_list) // 布局文件
}
}

activity_land_scape_list.xml

activity布局文件,内部套一个Fragment

layout/activity_land_scape_list.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:context=".ui.VideoFlowActivity">

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:keepScreenOn="true"
android:orientation="vertical"
android:background="@color/black">

<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container"
android:name="com.danale.edge.ui.devicelist.landscape.LandScapeListFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:layout="@layout/fragment_land_scape_list" />

</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

fragment_land_scape_list.xml

创建主页的Fragment组件,通过variable实现数据绑定

通过RecyclerView实现网格布局

layout/fragment_land_scape_list.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">

<data>

<variable
name="viewModel"
type="com.danale.edge.ui.devicelist.landscape.LandScapeListViewModel" />
</data>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/app_std_background_gray">

<com.danale.edge.ui.common.view.TopSafeAreaGuideLine
android:id="@+id/safe_area_top_guide"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="24dp"
tools:ignore="MissingConstraints" />

<include
android:id="@+id/component_title_bar"
layout="@layout/component_title_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/safe_area_top_guide" />


<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/component_title_bar" />

</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

LandScapeListFragment.kt

实现Fragment的布局和绑定,添加binding和ViewModel,在GridLayoutManager(requireContext(), 4)处设置布局样式

com/danale/edge/ui/devicelist/landscape/LandScapeListFragment.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
package com.danale.edge.ui.devicelist.landscape

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.get
import androidx.recyclerview.widget.GridLayoutManager
import com.danale.edge.R
import com.danale.edge.base.BaseFragment
import com.danale.edge.databinding.FragmentLandScapeListBinding
import com.danale.edge.usersdk.type.DeviceInfo


class LandScapeListFragment : BaseFragment() {

private lateinit var binding: FragmentLandScapeListBinding
private lateinit var viewModel: LandScapeListViewModel

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {

logger.i(TAG, "onCreateView")

binding =
DataBindingUtil.inflate(inflater, R.layout.fragment_land_scape_list, container, false) // 绑定数据
binding.lifecycleOwner = viewLifecycleOwner // 生命周期

viewModel = ViewModelProvider(requireActivity()).get() // 绑定ViewModel
observeNavigation(viewModel) // 处理BaseViewModel的导航请求

binding.recyclerView.apply {
adapter = viewModel.adapterForClassCode(DeviceInfo.DEVICE_TYPE_IPC)
// 一行四列
layoutManager = GridLayoutManager(requireContext(), 4)
}

return binding.root
}

override fun onResume() {
super.onResume()
setStatusBarDarkText(true) // 切换状态栏文本颜色,true为深色
viewModel.loadDeviceList() // 加载设备列表
}

}

LandScapeListViewModel.kt

ViewModel文件,用来实现内部逻辑

com/danale/edge/ui/devicelist/landscape/LandScapeListViewModel.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package com.danale.edge.ui.devicelist.landscape

import android.app.Application
import android.content.Intent
import com.danale.edge.base.BaseNavigation
import com.danale.edge.ui.devicelist.ListDeviceViewModel
import com.danale.edge.ui.devicelist.card.CardAdapter
import com.danale.edge.usersdk.type.DeviceInfo
import dagger.hilt.android.lifecycle.HiltViewModel
import javax.inject.Inject

@HiltViewModel
class LandScapeListViewModel @Inject constructor(application: Application) :
ListDeviceViewModel(application), CardAdapter.Delegate {

/**
*跳转直播
*/
override fun navigateDevicePage(item: DeviceInfo) {
val type = item.getDeviceType()
if (type != DeviceInfo.DEVICE_TYPE_IPC) return // 判断设备类型

item.getDeviceInfoDid()?.let { did ->
val intent = Intent().apply {
putExtra(BaseNavigation.Constants.EXTRA_PLATFORM_DEVICE_ID_32, did)
putExtra(
BaseNavigation.Constants.EXTRA_PLATFORM_DEVICE_LIKE_NAME,
item.getDeviceInfoLikeName()
)
}
navigationRequiredEvent.postValue(
BaseNavigation(
BaseNavigation.Route.CONTROL_LAND_SCAPE_IPC, // 进入直播页
intent
)
)
}
}

}

直播页

LandScapeIPCActivity.kt

创建一个直播页的activity

com/danale/edge/ui/devicecontrol/landscape/LandScapeIPCActivity.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
package com.danale.edge.ui.devicecontrol.landscape

import android.os.Bundle
import androidx.activity.viewModels
import com.danale.edge.R
import com.danale.edge.base.BaseActivity
import com.danale.edge.base.BaseNavigation
import com.danale.edge.foundation.privacy.Fuzzy
import com.danale.edge.ui.devicecontrol.common.DeviceLiveViewModel

/**
* IPC入口Activity到直播页
*/

class LandScapeIPCActivity : BaseActivity() {

private val liveVm: DeviceLiveViewModel by viewModels()

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_land_scape_ipc) // 布局文件

intent?.let { intent ->
intent.getStringExtra(BaseNavigation.Constants.EXTRA_PLATFORM_DEVICE_ID_32)
?.let { did ->
logger.d(TAG, "onCreate, did=${Fuzzy.interval(did)}")
liveVm.deviceId32 = did
}
intent.getStringExtra(BaseNavigation.Constants.EXTRA_PLATFORM_DEVICE_LIKE_NAME)
?.let { name ->
logger.d(TAG, "onCreate, name=${Fuzzy.interval(name)}")
liveVm.observableDeviceName.set(name)
}
}
}
}

activity_land_scape_ipc.xml

layout/activity_land_scape_ipc.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout 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"
tools:context=".ui.devicecontrol.landscape.LandScapeIPCActivity">

<androidx.fragment.app.FragmentContainerView
android:id="@+id/fragment_container_ipc"
android:name="com.danale.edge.ui.devicecontrol.landscape.LandScapeIPCLiveFragment"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:layout="@layout/fragment_land_scape_ipc_live" />

</androidx.constraintlayout.widget.ConstraintLayout>

fragment_land_scape_ipc_live.xml

layout/fragment_land_scape_ipc_live.xmll

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">

<data>

<variable
name="viewModel"
type="com.danale.edge.ui.devicecontrol.common.DeviceLiveViewModel" />

<import type="android.view.View" />
</data>

<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black">

<!--顶部安全距离-->
<com.danale.edge.ui.common.view.TopSafeAreaGuideLine
android:id="@+id/safe_area_top_guide"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintGuide_begin="24dp"
tools:ignore="MissingConstraints" />

<com.danale.edge.ui.common.view.ScaleTextureView
android:id="@+id/texture_view"
android:layout_width="match_parent"
android:layout_height="match_parent" />

<androidx.appcompat.widget.LinearLayoutCompat
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="start"
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:paddingVertical="4dp"
android:visibility="visible"
app:layout_constraintTop_toBottomOf="@id/safe_area_top_guide">

<!--返回按钮-->
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/iv_back_icon"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_marginHorizontal="3dp"
android:layout_marginVertical="15dp"
android:padding="6dp"
app:srcCompat="@mipmap/ic_back_white" />

</androidx.appcompat.widget.LinearLayoutCompat>


<!--重新加载组件-->
<com.github.ybq.android.spinkit.SpinKitView
style="@style/SpinKitView.ThreeBounce"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="@{viewModel.observableVideoState.showLoadingUi ? View.VISIBLE : View.GONE}"
app:SpinKit_Color="@color/white"
app:layout_constraintBottom_toBottomOf="@id/texture_view"
app:layout_constraintEnd_toEndOf="@id/texture_view"
app:layout_constraintStart_toStartOf="@id/texture_view"
app:layout_constraintTop_toTopOf="@id/texture_view" />

<androidx.appcompat.widget.AppCompatTextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_round_color"
android:backgroundTint="@color/app_std_white_20"
android:gravity="center"
android:onClick="@{viewModel::onClickLoadLiveVideo}"
android:paddingHorizontal="24dp"
android:paddingVertical="8dp"
android:text="@string/text_retry_loading"
android:textColor="@color/white"
android:visibility="@{viewModel.observableVideoState.showErrorUi ? View.VISIBLE : View.GONE}"
app:layout_constraintBottom_toBottomOf="@id/texture_view"
app:layout_constraintEnd_toEndOf="@id/texture_view"
app:layout_constraintStart_toStartOf="@id/texture_view"
app:layout_constraintTop_toTopOf="@id/texture_view" />

</androidx.constraintlayout.widget.ConstraintLayout>
</layout>

image

LandScapeIPCLiveFragment.kt

com/danale/edge/ui/devicecontrol/landscape/LandScapeIPCLiveFragment.kt

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
package com.danale.edge.ui.devicecontrol.landscape

import android.content.res.Configuration
import android.graphics.SurfaceTexture
import android.os.Bundle
import android.view.*
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.get
import com.danale.edge.R
import com.danale.edge.base.BaseFragment
import com.danale.edge.databinding.FragmentLandScapeIpcLiveBinding
import com.danale.edge.foundation.media.PcmTrack
import com.danale.edge.ui.common.view.ScaleTextureView
import com.danale.edge.ui.devicecontrol.common.DeviceLiveViewModel
import com.danale.edge.usersdk.usecase.UserSdkUseCase
import javax.inject.Inject

/**
* 直播页具体内容
*/
class LandScapeIPCLiveFragment : BaseFragment(), TextureView.SurfaceTextureListener {

private lateinit var binding: FragmentLandScapeIpcLiveBinding
private lateinit var viewModel: DeviceLiveViewModel
private lateinit var requestPermissionLauncher: ActivityResultLauncher<String>

private var surfaceCallback: ((Surface) -> Unit)? = null
private var surface: Surface? = null

@Inject
lateinit var sdk: UserSdkUseCase

lateinit var deviceId32: String

override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
binding =
DataBindingUtil.inflate(
inflater,
R.layout.fragment_land_scape_ipc_live,
container,
false
) // 数据绑定
binding.lifecycleOwner = viewLifecycleOwner // 生命周期

viewModel = ViewModelProvider(requireActivity()).get() // ViewModel(使用原有的
binding.viewModel = this.viewModel


deviceId32 = viewModel.deviceId32.toString() // 获取设备id

// 云台控制
binding.textureView.surfaceTextureListener = this
binding.textureView.setCanTouch(true)
binding.textureView.setScaleTextureOnTouchLimitListener(object :
ScaleTextureView.ScaleTextureOnTouchLimitListener {
override fun onLeftLimit() {
logger.d(TAG, "video gesture, left limit")
viewModel.setPtzNeedStop(false)
viewModel.sendRequest(DeviceLiveViewModel.IPCDirection.LEFT)
}

override fun onRightLimit() {
logger.d(TAG, "video gesture, right limit")
viewModel.setPtzNeedStop(false)
viewModel.sendRequest(DeviceLiveViewModel.IPCDirection.RIGHT)
}

override fun onTopLimit() {
logger.d(TAG, "video gesture, top limit")
viewModel.setPtzNeedStop(false)
viewModel.sendRequest(DeviceLiveViewModel.IPCDirection.TOP)
}

override fun onBottomLimit() {
logger.d(TAG, "video gesture, bottom limit")
viewModel.setPtzNeedStop(false)
viewModel.sendRequest(DeviceLiveViewModel.IPCDirection.BOTTOM)
}

override fun onFinish() {
logger.d(TAG, "video gesture, finish")
viewModel.setPtzNeedStop(true)
viewModel.sendRequest(DeviceLiveViewModel.IPCDirection.STOP)
}

override fun onScale(scale: Float) {
logger.d(TAG, "video gesture, scale $scale")
}

override fun onOnlyClick() {
logger.d(TAG, "video gesture, click")
viewModel.toggleOverlayVisible()
}

})

// 返回按钮
binding.ivBackIcon.setOnClickListener {
onBackPressed()
}

requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {
logger.d(TAG, "mic permission result, it=$it")
}

return binding.root
}

override fun onResume() {
super.onResume()
PcmTrack.startAudioTrackSync()
ensureSurface {
viewModel.startLive(it)
}
}

override fun onPause() {
super.onPause()
PcmTrack.releaseAudioTrackSync()
viewModel.releaseDeviceAndPlayer()
}

private fun ensureSurface(callback: (Surface) -> Unit) {
val s = surface
if (s != null) {
callback(s)
return
} else {
surfaceCallback = callback
}
}

override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
logger.i(TAG, "onSurfaceTextureAvailable")
val s = Surface(surface)
this.surface = s
surfaceCallback?.let {
it(s)
}
surfaceCallback = null
}

override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
logger.i(TAG, "onSurfaceTextureSizeChanged")
}

override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
logger.i(TAG, "onSurfaceTextureDestroyed")
this.surface = null
return true
}

override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {}

override fun onConfigurationChanged(newConfig: Configuration) {
super.onConfigurationChanged(newConfig)
viewModel.setOrientation(newConfig.orientation)
}
}

云台控制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
fun sendRequest(direction: IPCDirection) {
val did = deviceId32 ?: return
val thing = deviceThingInfo ?: return

viewModelScope.launch {
val stop = DpCommand("direction", "8", "1")
val flipState = performOnIOWithDefault(DpFlipState.NORMAL) {
sdk.getDeviceFlipStatusWithCache(thing).await()
}

// 判断旋转的方向
// DpFlipState.UP_SIDE_DOWN是设备倒置的时候,需要调转方向
val request = when (direction) {
IPCDirection.LEFT -> if (flipState == DpFlipState.UP_SIDE_DOWN) {
DpCommand("direction", "8", "5")
} else {
DpCommand("direction", "8", "4")
}
IPCDirection.RIGHT -> if (flipState == DpFlipState.UP_SIDE_DOWN) {
DpCommand("direction", "8", "4")
} else {
DpCommand("direction", "8", "5")
}
IPCDirection.TOP -> if (flipState == DpFlipState.UP_SIDE_DOWN) {
DpCommand("direction", "8", "3")
} else {
DpCommand("direction", "8", "2")
}
IPCDirection.BOTTOM -> if (flipState == DpFlipState.UP_SIDE_DOWN) {
DpCommand("direction", "8", "2")
} else {
DpCommand("direction", "8", "3")
}
else -> stop
}
withContext(Dispatchers.IO) {
try {
sdk.commandDeviceDp(did, DpCode.PTZ_CONTROL, listOf(request)).await()
if (ptzNeedStop.get()) {
// 结果返回的时候操作已经停止了,为了避免设备一直转下去,在这种情况下发一个停止信令
logger.w(TAG, "ptz, control, need stop")
sdk.commandDeviceDp(did, DpCode.PTZ_CONTROL, listOf(stop)).await()
}
} catch (e: Exception) {
logger.e(TAG, "ptz, control, error", e)
try {
sdk.commandDeviceDp(did, DpCode.PTZ_CONTROL, listOf(stop)).await()
} catch (_: Exception) {
}
}
}
}

}


// 在设备开始转的时候传入false,停止的时候传入true
// 由于控制设备的方式是异步的,为了避免异步的顺序问题可能导致在停止后会收到旋转的消息,设备会一直转下去
// 通过设定一个flag,在true的时候发送停止指令
fun setPtzNeedStop(flag: Boolean){
ptzNeedStop.set(flag)
}

强制横屏

Androidmanifest.xml中添加activity并设置intent-filter

1
2
3
4
5
6
7
8
9
10
11
<activity
android:name="com.danale.edge.ui.devicelist.landscape.LandScapeListActivity"
android:exported="true"
android:screenOrientation="landscape"
android:configChanges="orientation|keyboardHidden">

<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
Powered by Hexo & Theme Keep
Unique Visitor Page View