Skip to content

Commit e5d8bad

Browse files
committed
Audio cast feature
1 parent b766b77 commit e5d8bad

File tree

35 files changed

+1377
-249
lines changed

35 files changed

+1377
-249
lines changed
Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,97 @@
11
package com.ismartcoding.plain.features.media
22

3+
import com.ismartcoding.lib.logcat.LogCat
34
import com.ismartcoding.lib.upnp.UPnPDevice
45
import com.ismartcoding.plain.data.IMedia
6+
import kotlinx.coroutines.flow.MutableStateFlow
7+
import kotlinx.coroutines.flow.StateFlow
8+
import kotlinx.coroutines.flow.asStateFlow
59

610
object CastPlayer {
711
var currentDevice: UPnPDevice? = null
8-
var items: List<IMedia>? = null
9-
var currentUri: String = ""
12+
13+
private val _items = MutableStateFlow<List<IMedia>>(emptyList())
14+
val items: StateFlow<List<IMedia>> = _items.asStateFlow()
15+
16+
private val _currentUri = MutableStateFlow("")
17+
val currentUri: StateFlow<String> = _currentUri.asStateFlow()
18+
val isPlaying = MutableStateFlow(false)
19+
20+
// 播放进度相关状态
21+
val progress = MutableStateFlow(0f) // 当前播放位置(秒)
22+
val duration = MutableStateFlow(0f) // 总时长(秒)
23+
val supportsCallback = MutableStateFlow(false) // 是否支持回调
24+
1025
var sid: String = ""
26+
27+
fun setItems(newItems: List<IMedia>) {
28+
_items.value = newItems
29+
}
30+
31+
fun addItem(item: IMedia) {
32+
_items.value = _items.value + item
33+
}
34+
35+
fun removeItem(item: IMedia) {
36+
_items.value = _items.value.filter { it.path != item.path }
37+
}
38+
39+
fun removeItemAt(index: Int) {
40+
val currentList = _items.value.toMutableList()
41+
if (index in 0 until currentList.size) {
42+
currentList.removeAt(index)
43+
_items.value = currentList
44+
}
45+
}
46+
47+
fun clearItems() {
48+
_items.value = emptyList()
49+
_currentUri.value = ""
50+
isPlaying.value = false
51+
progress.value = 0f
52+
duration.value = 0f
53+
supportsCallback.value = false
54+
}
55+
56+
fun setCurrentUri(uri: String) {
57+
_currentUri.value = uri
58+
}
59+
60+
fun reorderItems(fromIndex: Int, toIndex: Int) {
61+
val currentList = _items.value.toMutableList()
62+
if (fromIndex in 0 until currentList.size && toIndex in 0 until currentList.size) {
63+
val item = currentList.removeAt(fromIndex)
64+
currentList.add(toIndex, item)
65+
_items.value = currentList
66+
}
67+
}
68+
69+
/**
70+
* 解析 UPnP 时间格式 (HH:MM:SS 或 HH:MM:SS.mmm) 到秒数
71+
*/
72+
fun parseTimeToSeconds(timeString: String): Float {
73+
if (timeString.isEmpty() || timeString == "NOT_IMPLEMENTED") return 0f
74+
75+
return try {
76+
val parts = timeString.split(":")
77+
if (parts.size >= 3) {
78+
val hours = parts[0].toFloat()
79+
val minutes = parts[1].toFloat()
80+
val seconds = parts[2].split(".")[0].toFloat() // 忽略毫秒部分
81+
hours * 3600 + minutes * 60 + seconds
82+
} else {
83+
0f
84+
}
85+
} catch (e: Exception) {
86+
0f
87+
}
88+
}
89+
90+
/**
91+
* 更新播放位置信息
92+
*/
93+
fun updatePositionInfo(relTime: String, trackDuration: String) {
94+
progress.value = parseTimeToSeconds(relTime)
95+
duration.value = parseTimeToSeconds(trackDuration)
96+
}
1197
}

app/src/main/java/com/ismartcoding/plain/ui/components/mediaviewer/previewer/ImagePreviewActions.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import com.ismartcoding.plain.helpers.ShareHelper
4141
import com.ismartcoding.plain.ui.base.HorizontalSpace
4242
import com.ismartcoding.plain.ui.base.PMiniButton
4343
import com.ismartcoding.plain.ui.base.PMiniOutlineButton
44-
import com.ismartcoding.plain.ui.components.CastDialog
44+
import com.ismartcoding.plain.ui.page.cast.CastDialog
4545
import com.ismartcoding.plain.ui.helpers.DialogHelper
4646
import com.ismartcoding.plain.ui.models.CastViewModel
4747
import com.ismartcoding.plain.ui.components.mediaviewer.PreviewItem

app/src/main/java/com/ismartcoding/plain/ui/components/mediaviewer/previewer/VideoPreviewActions.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ import com.ismartcoding.plain.ui.base.HorizontalSpace
5858
import com.ismartcoding.plain.ui.base.PMiniButton
5959
import com.ismartcoding.plain.ui.base.PMiniOutlineButton
6060
import com.ismartcoding.plain.ui.base.PlayerSlider
61-
import com.ismartcoding.plain.ui.components.CastDialog
61+
import com.ismartcoding.plain.ui.page.cast.CastDialog
6262
import com.ismartcoding.plain.ui.components.mediaviewer.video.VideoState
6363
import com.ismartcoding.plain.ui.helpers.DialogHelper
6464
import com.ismartcoding.plain.ui.models.CastViewModel

app/src/main/java/com/ismartcoding/plain/ui/models/CastViewModel.kt

Lines changed: 121 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,25 +10,32 @@ import com.ismartcoding.lib.logcat.LogCat
1010
import com.ismartcoding.lib.upnp.UPnPController
1111
import com.ismartcoding.lib.upnp.UPnPDevice
1212
import com.ismartcoding.lib.upnp.UPnPDiscovery
13+
import com.ismartcoding.plain.data.IMedia
1314
import com.ismartcoding.plain.features.media.CastPlayer
1415
import com.ismartcoding.plain.helpers.UrlHelper
16+
import com.ismartcoding.plain.ui.helpers.DialogHelper
1517
import io.ktor.client.HttpClient
1618
import io.ktor.client.call.body
1719
import io.ktor.client.engine.cio.CIO
1820
import io.ktor.client.request.get
1921
import io.ktor.http.HttpStatusCode
2022
import kotlinx.coroutines.Dispatchers
23+
import kotlinx.coroutines.delay
2124
import kotlinx.coroutines.flow.MutableStateFlow
2225
import kotlinx.coroutines.flow.StateFlow
2326
import kotlinx.coroutines.flow.buffer
2427
import kotlinx.coroutines.flow.flowOn
2528
import kotlinx.coroutines.launch
29+
import kotlinx.coroutines.Job
2630

2731
class CastViewModel : ViewModel() {
2832
private val _itemsFlow = MutableStateFlow(mutableStateListOf<UPnPDevice>())
2933
val itemsFlow: StateFlow<List<UPnPDevice>> get() = _itemsFlow
3034
var castMode = mutableStateOf(false)
3135
var showCastDialog = mutableStateOf(false)
36+
val isLoading = mutableStateOf(false)
37+
38+
private var positionUpdateJob: Job? = null
3239

3340
fun enterCastMode() {
3441
castMode.value = true
@@ -44,17 +51,110 @@ class CastViewModel : ViewModel() {
4451
val device = CastPlayer.currentDevice ?: return
4552
viewModelScope.launch(Dispatchers.IO) {
4653
UPnPController.stopAVTransportAsync(device)
54+
CastPlayer.isPlaying.value = false
55+
56+
// 清理投屏状态
57+
if (CastPlayer.sid.isNotEmpty()) {
58+
UPnPController.unsubscribeEvent(device, CastPlayer.sid)
59+
CastPlayer.sid = ""
60+
}
61+
CastPlayer.supportsCallback.value = false
62+
CastPlayer.progress.value = 0f
63+
CastPlayer.duration.value = 0f
64+
65+
// 取消位置更新作业
66+
positionUpdateJob?.cancel()
67+
positionUpdateJob = null
4768
}
4869
}
4970

5071
fun cast(path: String) {
5172
val device = CastPlayer.currentDevice ?: return
5273
viewModelScope.launch(Dispatchers.IO) {
53-
CastPlayer.currentUri = path
54-
UPnPController.setAVTransportURIAsync(device, UrlHelper.getMediaHttpUrl(path))
55-
if (CastPlayer.sid.isNotEmpty()) {
56-
UPnPController.unsubscribeEvent(device, CastPlayer.sid)
57-
CastPlayer.sid = ""
74+
isLoading.value = true
75+
CastPlayer.setCurrentUri(path)
76+
try {
77+
UPnPController.setAVTransportURIAsync(device, UrlHelper.getMediaHttpUrl(path))
78+
CastPlayer.isPlaying.value = true
79+
if (CastPlayer.sid.isNotEmpty()) {
80+
UPnPController.unsubscribeEvent(device, CastPlayer.sid)
81+
CastPlayer.sid = ""
82+
}
83+
84+
// 尝试订阅事件回调
85+
trySubscribeEvent()
86+
} catch (e: Exception) {
87+
DialogHelper.showErrorMessage(e.message ?: "Cast failed")
88+
} finally {
89+
isLoading.value = false
90+
}
91+
}
92+
}
93+
94+
fun cast(item: IMedia) {
95+
val device = CastPlayer.currentDevice ?: return
96+
viewModelScope.launch(Dispatchers.IO) {
97+
CastPlayer.setCurrentUri(item.path)
98+
isLoading.value = true
99+
val castItems = CastPlayer.items.value
100+
val isInQueue = castItems.any { it.path == item.path }
101+
if (!isInQueue) {
102+
CastPlayer.addItem(item)
103+
}
104+
try {
105+
UPnPController.setAVTransportURIAsync(device, UrlHelper.getMediaHttpUrl(item.path))
106+
CastPlayer.isPlaying.value = true
107+
if (CastPlayer.sid.isNotEmpty()) {
108+
UPnPController.unsubscribeEvent(device, CastPlayer.sid)
109+
CastPlayer.sid = ""
110+
}
111+
112+
// 尝试订阅事件回调
113+
trySubscribeEvent()
114+
} catch (e: Exception) {
115+
DialogHelper.showErrorMessage(e.message ?: "Cast failed")
116+
} finally {
117+
isLoading.value = false
118+
}
119+
}
120+
}
121+
122+
private suspend fun trySubscribeEvent() {
123+
val device = CastPlayer.currentDevice ?: return
124+
try {
125+
val sid = UPnPController.subscribeEvent(device, UrlHelper.getCastCallbackUrl())
126+
if (sid.isNotEmpty()) {
127+
CastPlayer.sid = sid
128+
CastPlayer.supportsCallback.value = true
129+
// 开始定期获取播放位置
130+
startPositionUpdater()
131+
} else {
132+
CastPlayer.supportsCallback.value = false
133+
}
134+
} catch (e: Exception) {
135+
CastPlayer.supportsCallback.value = false
136+
}
137+
}
138+
139+
private fun startPositionUpdater() {
140+
val device = CastPlayer.currentDevice ?: return
141+
if (!CastPlayer.supportsCallback.value) return
142+
143+
// 取消之前的作业
144+
positionUpdateJob?.cancel()
145+
146+
positionUpdateJob = viewModelScope.launch(Dispatchers.IO) {
147+
while (CastPlayer.currentUri.value.isNotEmpty() && CastPlayer.supportsCallback.value) {
148+
try {
149+
if (CastPlayer.isPlaying.value) {
150+
val positionInfo = UPnPController.getPositionInfoAsync(device)
151+
CastPlayer.updatePositionInfo(positionInfo.relTime, positionInfo.trackDuration)
152+
}
153+
} catch (e: Exception) {
154+
// 获取位置信息失败,可能不支持
155+
break
156+
}
157+
delay(1000) // 每秒更新一次
58158
}
59159
}
60160
}
@@ -84,4 +184,20 @@ class CastViewModel : ViewModel() {
84184
_itemsFlow.value.add(device)
85185
}
86186
}
187+
188+
fun playCast() {
189+
val device = CastPlayer.currentDevice ?: return
190+
viewModelScope.launch(Dispatchers.IO) {
191+
UPnPController.playAVTransportAsync(device)
192+
CastPlayer.isPlaying.value = true
193+
}
194+
}
195+
196+
fun pauseCast() {
197+
val device = CastPlayer.currentDevice ?: return
198+
viewModelScope.launch(Dispatchers.IO) {
199+
UPnPController.pauseAVTransportAsync(device)
200+
CastPlayer.isPlaying.value = false
201+
}
202+
}
87203
}

0 commit comments

Comments
 (0)