mirror of
https://github.com/oxyroid/M3UAndroid.git
synced 2025-08-06 14:59:48 +08:00
fix: code clean up.
This commit is contained in:
2
.idea/vcs.xml
generated
2
.idea/vcs.xml
generated
@ -2,7 +2,5 @@
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/extension/api" vcs="Git" />
|
||||
<mapping directory="$PROJECT_DIR$/extension/repos" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
@ -4,7 +4,6 @@ import android.app.Application
|
||||
import android.os.Build
|
||||
import com.m3u.core.architecture.Abi
|
||||
import com.m3u.core.architecture.Publisher
|
||||
import com.m3u.core.util.context.tv
|
||||
import javax.inject.Inject
|
||||
|
||||
class AppPublisher @Inject constructor(private val application: Application) : Publisher {
|
||||
@ -12,10 +11,6 @@ class AppPublisher @Inject constructor(private val application: Application) : P
|
||||
override val versionName: String = BuildConfig.VERSION_NAME
|
||||
override val versionCode: Int = BuildConfig.VERSION_CODE
|
||||
override val debug: Boolean = BuildConfig.DEBUG
|
||||
override val snapshot: Boolean = false
|
||||
override val lite: Boolean = false
|
||||
override val model: String = Build.MODEL
|
||||
override val abi: Abi = Abi.of(Build.SUPPORTED_ABIS[0])
|
||||
override val tv: Boolean
|
||||
get() = application.resources.configuration.tv
|
||||
}
|
@ -3,49 +3,31 @@ package com.m3u.smartphone.ui
|
||||
import android.app.ActivityOptions
|
||||
import android.content.Intent
|
||||
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.SettingsRemote
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.FloatingActionButtonDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.NavHostController
|
||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navOptions
|
||||
import com.m3u.smartphone.ui.common.connect.RemoteControlSheet
|
||||
import com.m3u.smartphone.ui.common.connect.RemoteControlSheetValue
|
||||
import com.m3u.data.tv.model.RemoteDirection
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.m3u.core.architecture.preferences.PreferencesKeys
|
||||
import com.m3u.core.architecture.preferences.preferenceOf
|
||||
import com.m3u.smartphone.ui.business.channel.PlayerActivity
|
||||
import com.m3u.smartphone.ui.material.model.LocalSpacing
|
||||
import com.m3u.smartphone.ui.common.AppNavHost
|
||||
import com.m3u.smartphone.ui.common.Scaffold
|
||||
import com.m3u.smartphone.ui.material.components.Destination
|
||||
import com.m3u.smartphone.ui.material.components.FontFamilies
|
||||
import com.m3u.smartphone.ui.material.components.SnackHost
|
||||
import com.m3u.smartphone.ui.material.model.LocalSpacing
|
||||
|
||||
@Composable
|
||||
fun App(
|
||||
@ -71,23 +53,9 @@ fun App(
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
}
|
||||
|
||||
// for tvs
|
||||
val broadcastCodeOnTv by viewModel.broadcastCodeOnTv.collectAsStateWithLifecycle()
|
||||
|
||||
// for smartphones
|
||||
val remoteControlSheetValue by viewModel.remoteControlSheetValue.collectAsStateWithLifecycle()
|
||||
|
||||
AppImpl(
|
||||
navController = navController,
|
||||
onBackPressed = onBackPressed.takeUnless { shouldDispatchBackStack },
|
||||
checkTvCodeOnSmartphone = viewModel::checkTvCodeOnSmartphone,
|
||||
forgetTvCodeOnSmartphone = viewModel::forgetTvCodeOnSmartphone,
|
||||
broadcastCodeOnTv = broadcastCodeOnTv,
|
||||
isRemoteControlSheetVisible = viewModel.isConnectSheetVisible,
|
||||
remoteControlSheetValue = remoteControlSheetValue,
|
||||
onRemoteDirection = viewModel::onRemoteDirection,
|
||||
openRemoteControlSheet = { viewModel.isConnectSheetVisible = true },
|
||||
onCode = { viewModel.code = it },
|
||||
onDismissRequest = {
|
||||
viewModel.code = ""
|
||||
viewModel.isConnectSheetVisible = false
|
||||
@ -99,15 +67,7 @@ fun App(
|
||||
@Composable
|
||||
private fun AppImpl(
|
||||
navController: NavHostController,
|
||||
isRemoteControlSheetVisible: Boolean,
|
||||
remoteControlSheetValue: RemoteControlSheetValue,
|
||||
broadcastCodeOnTv: String?,
|
||||
onBackPressed: (() -> Unit)?,
|
||||
openRemoteControlSheet: () -> Unit,
|
||||
onCode: (String) -> Unit,
|
||||
checkTvCodeOnSmartphone: () -> Unit,
|
||||
forgetTvCodeOnSmartphone: () -> Unit,
|
||||
onRemoteDirection: (RemoteDirection) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
@ -142,7 +102,7 @@ private fun AppImpl(
|
||||
Scaffold(
|
||||
rootDestination = rootDestination,
|
||||
onBackPressed = onBackPressed,
|
||||
navigateToChannel =navigateToChannel,
|
||||
navigateToChannel = navigateToChannel,
|
||||
navigateToRootDestination = {
|
||||
navController.navigate(it.name, navOptions {
|
||||
popUpTo(it.name) {
|
||||
@ -170,52 +130,6 @@ private fun AppImpl(
|
||||
.padding(spacing.medium)
|
||||
) {
|
||||
SnackHost(Modifier.weight(1f))
|
||||
AnimatedVisibility(
|
||||
visible = remoteControl,
|
||||
enter = scaleIn(initialScale = 0.65f) + fadeIn(),
|
||||
exit = scaleOut(targetScale = 0.65f) + fadeOut()
|
||||
) {
|
||||
FloatingActionButton(
|
||||
elevation = FloatingActionButtonDefaults.elevation(
|
||||
defaultElevation = spacing.none,
|
||||
pressedElevation = spacing.none,
|
||||
focusedElevation = spacing.extraSmall,
|
||||
hoveredElevation = spacing.extraSmall
|
||||
),
|
||||
onClick = openRemoteControlSheet
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.SettingsRemote,
|
||||
contentDescription = "remote control"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RemoteControlSheet(
|
||||
value = remoteControlSheetValue,
|
||||
visible = isRemoteControlSheetVisible,
|
||||
onCode = onCode,
|
||||
checkTvCodeOnSmartphone = checkTvCodeOnSmartphone,
|
||||
forgetTvCodeOnSmartphone = forgetTvCodeOnSmartphone,
|
||||
onRemoteDirection = onRemoteDirection,
|
||||
onDismissRequest = onDismissRequest
|
||||
)
|
||||
|
||||
Crossfade(
|
||||
targetState = broadcastCodeOnTv,
|
||||
label = "broadcast-code-on-tv",
|
||||
modifier = Modifier
|
||||
.padding(spacing.medium)
|
||||
.align(Alignment.BottomEnd)
|
||||
) { code ->
|
||||
if (code != null) {
|
||||
Text(
|
||||
text = code,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontFamily = FontFamilies.JetbrainsMono
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -3,154 +3,24 @@ package com.m3u.smartphone.ui
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import androidx.work.WorkManager
|
||||
import com.m3u.smartphone.ui.common.connect.RemoteControlSheetValue
|
||||
import com.m3u.core.architecture.Publisher
|
||||
import com.m3u.core.architecture.preferences.PreferencesKeys
|
||||
import com.m3u.core.architecture.preferences.Settings
|
||||
import com.m3u.core.architecture.preferences.flowOf
|
||||
import com.m3u.data.api.TvApiDelegate
|
||||
import com.m3u.data.tv.model.RemoteDirection
|
||||
import com.m3u.data.repository.tv.ConnectionToTvValue
|
||||
import com.m3u.data.repository.tv.TvRepository
|
||||
import com.m3u.data.repository.playlist.PlaylistRepository
|
||||
import com.m3u.data.repository.programme.ProgrammeRepository
|
||||
import com.m3u.data.service.Messager
|
||||
import com.m3u.data.worker.SubscriptionWorker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
@HiltViewModel
|
||||
class AppViewModel @Inject constructor(
|
||||
messager: Messager,
|
||||
private val playlistRepository: PlaylistRepository,
|
||||
private val programmeRepository: ProgrammeRepository,
|
||||
private val tvRepository: TvRepository,
|
||||
private val tvApi: TvApiDelegate,
|
||||
private val workManager: WorkManager,
|
||||
private val publisher: Publisher,
|
||||
private val settings: Settings
|
||||
) : ViewModel() {
|
||||
init {
|
||||
refreshProgrammes()
|
||||
}
|
||||
|
||||
val broadcastCodeOnTv: StateFlow<String?> = tvRepository
|
||||
.broadcastCodeOnTv
|
||||
.map { code -> code?.let { convertToPaddedString(it) } }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = null
|
||||
)
|
||||
|
||||
private val tvCodeOnSmartphone = MutableSharedFlow<String>()
|
||||
|
||||
private val connectionToTvValue: StateFlow<ConnectionToTvValue> =
|
||||
tvCodeOnSmartphone.flatMapLatest { code ->
|
||||
if (code.isNotEmpty()) tvRepository.connectToTv(code.toInt())
|
||||
else {
|
||||
tvRepository.disconnectToTv()
|
||||
flowOf(ConnectionToTvValue.Idle())
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
initialValue = ConnectionToTvValue.Idle(),
|
||||
started = SharingStarted.WhileSubscribed(5_000)
|
||||
)
|
||||
|
||||
internal val remoteControlSheetValue: StateFlow<RemoteControlSheetValue> = combine(
|
||||
tvRepository.connected,
|
||||
snapshotFlow { code },
|
||||
connectionToTvValue
|
||||
) { tvInfo, code, connection ->
|
||||
when {
|
||||
tvInfo == null -> {
|
||||
RemoteControlSheetValue.Prepare(
|
||||
code = code,
|
||||
searchingOrConnecting = with(connection) {
|
||||
this is ConnectionToTvValue.Searching ||
|
||||
this is ConnectionToTvValue.Connecting
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
tvInfo.version != publisher.versionCode -> {
|
||||
this.code = ""
|
||||
// val query = UpdateKey(tvInfo.version, tvInfo.abi)
|
||||
// val state = states[query] ?: UpdateState.Idle
|
||||
// RemoteControlSheetValue.Update(
|
||||
// tvInfo = tvInfo,
|
||||
// state = state
|
||||
// )
|
||||
RemoteControlSheetValue.Prepare(
|
||||
code = code,
|
||||
searchingOrConnecting = with(connection) {
|
||||
this is ConnectionToTvValue.Searching ||
|
||||
this is ConnectionToTvValue.Connecting
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
this.code = ""
|
||||
RemoteControlSheetValue.DPad(
|
||||
tvInfo = tvInfo,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
initialValue = RemoteControlSheetValue.Idle,
|
||||
started = SharingStarted.WhileSubscribed(5_000L)
|
||||
)
|
||||
|
||||
private var checkTvCodeOnSmartphoneJob: Job? = null
|
||||
|
||||
internal fun checkTvCodeOnSmartphone() {
|
||||
viewModelScope.launch {
|
||||
tvCodeOnSmartphone.emit(code)
|
||||
}
|
||||
checkTvCodeOnSmartphoneJob?.cancel()
|
||||
checkTvCodeOnSmartphoneJob = settings
|
||||
.flowOf(PreferencesKeys.REMOTE_CONTROL)
|
||||
.onEach { remoteControl ->
|
||||
if (!remoteControl) {
|
||||
forgetTvCodeOnSmartphone()
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
internal fun forgetTvCodeOnSmartphone() {
|
||||
viewModelScope.launch {
|
||||
tvCodeOnSmartphone.emit("")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun onRemoteDirection(remoteDirection: RemoteDirection) {
|
||||
viewModelScope.launch {
|
||||
tvApi.remoteDirection(remoteDirection.value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshProgrammes() {
|
||||
viewModelScope.launch {
|
||||
val playlists = playlistRepository.getAllAutoRefresh()
|
||||
@ -166,13 +36,4 @@ class AppViewModel @Inject constructor(
|
||||
|
||||
var code by mutableStateOf("")
|
||||
var isConnectSheetVisible by mutableStateOf(false)
|
||||
val message = messager.message
|
||||
}
|
||||
|
||||
private fun convertToPaddedString(code: Int, length: Int = 6): String {
|
||||
val codeString = code.toString()
|
||||
check(codeString.length <= length) { "Code($code) length is out of limitation($length)." }
|
||||
return codeString.let {
|
||||
"0".repeat(length - it.length) + it
|
||||
}
|
||||
}
|
@ -101,7 +101,6 @@ fun SettingRoute(
|
||||
epgState = viewModel.epgState,
|
||||
localStorageState = viewModel.localStorageState,
|
||||
selectedState = viewModel.selectedState,
|
||||
forTvState = viewModel.forTvState,
|
||||
backingUpOrRestoring = backingUpOrRestoring,
|
||||
epgs = epgs,
|
||||
hiddenChannels = hiddenChannels,
|
||||
@ -147,7 +146,6 @@ private fun SettingScreen(
|
||||
passwordState: MutableState<String>,
|
||||
epgState: MutableState<String>,
|
||||
localStorageState: MutableState<Boolean>,
|
||||
forTvState: MutableState<Boolean>,
|
||||
versionName: String,
|
||||
versionCode: Int,
|
||||
backingUpOrRestoring: BackingUpAndRestoringState,
|
||||
@ -259,7 +257,6 @@ private fun SettingScreen(
|
||||
passwordState = passwordState,
|
||||
epgState = epgState,
|
||||
localStorageState = localStorageState,
|
||||
forTvState = forTvState,
|
||||
backingUpOrRestoring = backingUpOrRestoring,
|
||||
hiddenChannels = hiddenChannels,
|
||||
hiddenCategoriesWithPlaylists = hiddenCategoriesWithPlaylists,
|
||||
|
@ -52,8 +52,6 @@ import com.m3u.smartphone.ui.business.setting.components.EpgPlaylistItem
|
||||
import com.m3u.smartphone.ui.business.setting.components.HiddenChannelItem
|
||||
import com.m3u.smartphone.ui.business.setting.components.HiddenPlaylistGroupItem
|
||||
import com.m3u.smartphone.ui.business.setting.components.LocalStorageButton
|
||||
import com.m3u.smartphone.ui.business.setting.components.LocalStorageSwitch
|
||||
import com.m3u.smartphone.ui.business.setting.components.RemoteControlSubscribeSwitch
|
||||
import com.m3u.smartphone.ui.common.helper.LocalHelper
|
||||
|
||||
private enum class SubscriptionsFragmentPage {
|
||||
@ -71,7 +69,6 @@ internal fun SubscriptionsFragment(
|
||||
passwordState: MutableState<String>,
|
||||
epgState: MutableState<String>,
|
||||
localStorageState: MutableState<Boolean>,
|
||||
forTvState: MutableState<Boolean>,
|
||||
backingUpOrRestoring: BackingUpAndRestoringState,
|
||||
hiddenChannels: List<Channel>,
|
||||
hiddenCategoriesWithPlaylists: List<Pair<Playlist, String>>,
|
||||
@ -107,7 +104,6 @@ internal fun SubscriptionsFragment(
|
||||
usernameState = usernameState,
|
||||
passwordState = passwordState,
|
||||
localStorageState = localStorageState,
|
||||
forTvState = forTvState,
|
||||
backingUpOrRestoring = backingUpOrRestoring,
|
||||
epgState = epgState,
|
||||
onClipboard = onClipboard,
|
||||
@ -159,7 +155,6 @@ private fun MainContentImpl(
|
||||
usernameState: MutableState<String>,
|
||||
passwordState: MutableState<String>,
|
||||
localStorageState: MutableState<Boolean>,
|
||||
forTvState: MutableState<Boolean>,
|
||||
backingUpOrRestoring: BackingUpAndRestoringState,
|
||||
epgState: MutableState<String>,
|
||||
onClipboard: (String) -> Unit,
|
||||
@ -224,22 +219,6 @@ private fun MainContentImpl(
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
if (selectedState.value == DataSource.M3U) {
|
||||
LocalStorageSwitch(
|
||||
checked = localStorageState.value,
|
||||
onChanged = { localStorageState.value = it },
|
||||
enabled = !forTvState.value
|
||||
)
|
||||
}
|
||||
if (remoteControl) {
|
||||
RemoteControlSubscribeSwitch(
|
||||
checked = forTvState.value,
|
||||
onChanged = { forTvState.value = !forTvState.value },
|
||||
enabled = !localStorageState.value
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
@SuppressLint("InlinedApi")
|
||||
val postNotificationPermission = rememberPermissionState(
|
||||
@ -286,14 +265,14 @@ private fun MainContentImpl(
|
||||
|
||||
item {
|
||||
FilledTonalButton(
|
||||
enabled = !forTvState.value && backingUpOrRestoring == BackingUpAndRestoringState.NONE,
|
||||
enabled = backingUpOrRestoring == BackingUpAndRestoringState.NONE,
|
||||
onClick = backup,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(text = stringResource(string.feat_setting_label_backup).uppercase())
|
||||
}
|
||||
FilledTonalButton(
|
||||
enabled = !forTvState.value && backingUpOrRestoring == BackingUpAndRestoringState.NONE,
|
||||
enabled = backingUpOrRestoring == BackingUpAndRestoringState.NONE,
|
||||
onClick = restore,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
|
@ -1,93 +0,0 @@
|
||||
package com.m3u.smartphone.ui.common.connect
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m3u.smartphone.ui.material.model.LocalSpacing
|
||||
|
||||
@Composable
|
||||
internal fun CodeRow(
|
||||
code: String,
|
||||
length: Int,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val element = remember(code) { code.toCharArray().map { it.toString() } }
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = spacing.extraLarge,
|
||||
vertical = spacing.medium
|
||||
)
|
||||
.clickable(
|
||||
onClick = onClick,
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
)
|
||||
.then(modifier),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
repeat(length) { i ->
|
||||
CodeField(
|
||||
text = element.getOrNull(i).orEmpty()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CodeField(text: String) {
|
||||
Box(
|
||||
Modifier
|
||||
.padding(start = 4.dp, end = 4.dp)
|
||||
.size(40.dp, 45.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(.05f),
|
||||
shape = RoundedCornerShape(6.dp)
|
||||
)
|
||||
.border(
|
||||
BorderStroke(1.dp, MaterialTheme.colorScheme.onSurface.copy(.1f)),
|
||||
RoundedCornerShape(6.dp)
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
text = text,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
if (text.isBlank()) {
|
||||
Box(
|
||||
Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(start = 12.dp, end = 12.dp, bottom = 13.dp)
|
||||
.height(1.dp)
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(.15f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,68 +0,0 @@
|
||||
package com.m3u.smartphone.ui.common.connect
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.data.service.collectMessageAsState
|
||||
import com.m3u.data.tv.model.RemoteDirection
|
||||
import com.m3u.data.tv.model.TvInfo
|
||||
import com.m3u.smartphone.ui.material.model.LocalSpacing
|
||||
import com.m3u.smartphone.ui.material.components.FontFamilies
|
||||
|
||||
@Composable
|
||||
@InternalComposeApi
|
||||
internal fun DPadContent(
|
||||
tvInfo: TvInfo,
|
||||
onRemoteDirection: (RemoteDirection) -> Unit,
|
||||
forgetTvCodeOnSmartphone: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val spacing = LocalSpacing.current
|
||||
val message by collectMessageAsState()
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.medium)
|
||||
) {
|
||||
Text(
|
||||
text = tvInfo.model,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||
)
|
||||
Text(
|
||||
text = message.formatText().ifEmpty { " " },
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
maxLines = 1,
|
||||
fontFamily = FontFamilies.JetbrainsMono,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp, 0.dp, 16.dp, 8.dp)
|
||||
)
|
||||
|
||||
RemoteDirectionController(
|
||||
onRemoteDirection = onRemoteDirection
|
||||
)
|
||||
|
||||
TextButton(
|
||||
onClick = forgetTvCodeOnSmartphone,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
) {
|
||||
Text("DISCONNECT")
|
||||
}
|
||||
}
|
||||
}
|
@ -1,118 +0,0 @@
|
||||
package com.m3u.smartphone.ui.common.connect
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m3u.core.util.basic.title
|
||||
import com.m3u.core.wrapper.Message
|
||||
import com.m3u.data.service.collectMessageAsState
|
||||
import com.m3u.i18n.R
|
||||
|
||||
@Composable
|
||||
@InternalComposeApi
|
||||
internal fun PrepareContent(
|
||||
code: String,
|
||||
searchingOrConnecting: Boolean,
|
||||
checkTvCodeOnSmartphone: () -> Unit,
|
||||
onCode: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val message by collectMessageAsState()
|
||||
|
||||
val title = stringResource(R.string.feat_foryou_connect_title).title()
|
||||
val subtitle = if (message.level == Message.LEVEL_EMPTY) {
|
||||
stringResource(R.string.feat_foryou_connect_subtitle)
|
||||
} else {
|
||||
message.formatText()
|
||||
}
|
||||
Column(modifier) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||
)
|
||||
|
||||
AnimatedContent(
|
||||
targetState = subtitle,
|
||||
label = "subtitle",
|
||||
transitionSpec = {
|
||||
fadeIn() + slideInVertically { it } togetherWith fadeOut() + slideOutVertically { it }
|
||||
},
|
||||
) { subtitle ->
|
||||
Text(
|
||||
text = subtitle,
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 14.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.78f),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp, 8.dp, 16.dp, 0.dp)
|
||||
)
|
||||
}
|
||||
CodeRow(
|
||||
code = code,
|
||||
length = 6,
|
||||
onClick = {}
|
||||
)
|
||||
|
||||
TextButton(
|
||||
enabled = !searchingOrConnecting && code.length == 6,
|
||||
onClick = checkTvCodeOnSmartphone,
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
searchingOrConnecting -> "CONNECTING"
|
||||
else -> "CONNECT"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = !searchingOrConnecting,
|
||||
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }) + fadeOut()
|
||||
) {
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
VirtualNumberKeyboard(
|
||||
code = code,
|
||||
onCode = {
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
onCode(it)
|
||||
},
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,87 +0,0 @@
|
||||
package com.m3u.smartphone.ui.common.connect
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ModalBottomSheetProperties
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.m3u.data.tv.model.TvInfo
|
||||
import com.m3u.data.tv.model.RemoteDirection
|
||||
|
||||
@Immutable
|
||||
sealed class RemoteControlSheetValue {
|
||||
@Immutable
|
||||
data object Idle : RemoteControlSheetValue()
|
||||
|
||||
@Immutable
|
||||
data class Prepare(
|
||||
val code: String,
|
||||
val searchingOrConnecting: Boolean,
|
||||
) : RemoteControlSheetValue()
|
||||
|
||||
@Immutable
|
||||
data class DPad(
|
||||
val tvInfo: TvInfo,
|
||||
) : RemoteControlSheetValue()
|
||||
}
|
||||
|
||||
|
||||
@OptIn(InternalComposeApi::class)
|
||||
@Composable
|
||||
internal fun RemoteControlSheet(
|
||||
value: RemoteControlSheetValue,
|
||||
visible: Boolean,
|
||||
onCode: (String) -> Unit,
|
||||
checkTvCodeOnSmartphone: () -> Unit,
|
||||
forgetTvCodeOnSmartphone: () -> Unit,
|
||||
onRemoteDirection: (RemoteDirection) -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val searchingOrConnecting = with(value) {
|
||||
this is RemoteControlSheetValue.Prepare && searchingOrConnecting
|
||||
}
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
confirmValueChange = { !searchingOrConnecting }
|
||||
)
|
||||
|
||||
if (visible) {
|
||||
ModalBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = {
|
||||
if (!searchingOrConnecting) onDismissRequest()
|
||||
},
|
||||
properties = ModalBottomSheetProperties(
|
||||
shouldDismissOnBackPress = false
|
||||
),
|
||||
modifier = modifier
|
||||
) {
|
||||
Column {
|
||||
when (value) {
|
||||
is RemoteControlSheetValue.Prepare -> {
|
||||
PrepareContent(
|
||||
code = value.code,
|
||||
searchingOrConnecting = value.searchingOrConnecting,
|
||||
checkTvCodeOnSmartphone = checkTvCodeOnSmartphone,
|
||||
onCode = onCode
|
||||
)
|
||||
}
|
||||
|
||||
is RemoteControlSheetValue.DPad -> {
|
||||
DPadContent(
|
||||
tvInfo = value.tvInfo,
|
||||
onRemoteDirection = onRemoteDirection,
|
||||
forgetTvCodeOnSmartphone = forgetTvCodeOnSmartphone,
|
||||
)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,292 +0,0 @@
|
||||
package com.m3u.smartphone.ui.common.connect
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.animateOffsetAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.draggable2D
|
||||
import androidx.compose.foundation.gestures.rememberDraggable2DState
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.wrapContentSize
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowLeft
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowUp
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.isSpecified
|
||||
import androidx.compose.ui.geometry.isUnspecified
|
||||
import androidx.compose.ui.geometry.takeOrElse
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m3u.data.tv.model.RemoteDirection
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Composable
|
||||
internal fun RemoteDirectionController(
|
||||
onRemoteDirection: (RemoteDirection) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
containerColor: Color = MaterialTheme.colorScheme.surfaceVariant,
|
||||
contentColor: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
handleScaleZoom: Float = 0.25f,
|
||||
pressedScaleZoom: Float = 0.56f,
|
||||
safeDragThreshold: Float = 0.42f,
|
||||
safePressThreshold: Float = 0.35f,
|
||||
hapticFeedbackType: HapticFeedbackType? = HapticFeedbackType.LongPress
|
||||
) {
|
||||
LaunchedEffect(handleScaleZoom, safeDragThreshold, safePressThreshold) {
|
||||
check(handleScaleZoom in 0f..1f) { "handleScaleZoom should in range [0f, 1f]" }
|
||||
check(safeDragThreshold > 0f && safeDragThreshold < 1f) { "safeDragThreshold should in range (0f, 1f)" }
|
||||
check(safePressThreshold in 0f..1f) { "safePressThreshold should in range [0f, 1f]" }
|
||||
}
|
||||
var handled: Boolean by remember { mutableStateOf(false) }
|
||||
var pressed: Boolean by remember { mutableStateOf(false) }
|
||||
val currentHandleZoom: Float by animateFloatAsState(
|
||||
targetValue = when {
|
||||
handled -> handleScaleZoom
|
||||
pressed -> pressedScaleZoom
|
||||
else -> 0.35f
|
||||
},
|
||||
animationSpec = tween(400),
|
||||
label = "current-handle-zoom"
|
||||
)
|
||||
val currentHandleAlpha: Float by animateFloatAsState(
|
||||
targetValue = when {
|
||||
handled -> 0.78f
|
||||
else -> 0.35f
|
||||
},
|
||||
label = "current-handle-zoom"
|
||||
)
|
||||
val currentContainerColor by animateColorAsState(
|
||||
targetValue = containerColor.copy(alpha = if (handled) 0.45f else 1f),
|
||||
label = "current-container-color"
|
||||
)
|
||||
var draggedPosition: Offset by remember { mutableStateOf(Offset.Unspecified) }
|
||||
var pressedPosition: Offset by remember { mutableStateOf(Offset.Unspecified) }
|
||||
val draggable2DState = rememberDraggable2DState { offset ->
|
||||
draggedPosition += offset
|
||||
}
|
||||
var direction: RemoteDirection? by remember { mutableStateOf(null) }
|
||||
var currentDashboardRotationX: Float by remember { mutableFloatStateOf(0f) }
|
||||
var currentDashboardRotationY: Float by remember { mutableFloatStateOf(0f) }
|
||||
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
val currentPosition by animateOffsetAsState(
|
||||
targetValue = pressedPosition.takeOrElse { draggedPosition.takeOrElse { Offset.Zero } },
|
||||
label = "current-position"
|
||||
)
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
interactionSource
|
||||
.interactions
|
||||
.onEach { interaction ->
|
||||
when (interaction) {
|
||||
is PressInteraction -> {
|
||||
when (interaction) {
|
||||
is PressInteraction.Press -> {
|
||||
delay(150.milliseconds)
|
||||
pressed = true
|
||||
pressedPosition = interaction.pressPosition
|
||||
}
|
||||
|
||||
is PressInteraction.Cancel -> {
|
||||
pressed = false
|
||||
pressedPosition = Offset.Unspecified
|
||||
}
|
||||
|
||||
is PressInteraction.Release -> {
|
||||
pressed = false
|
||||
pressedPosition = Offset.Unspecified
|
||||
direction?.let { onRemoteDirection(it) }
|
||||
hapticFeedbackType?.let { type ->
|
||||
hapticFeedback.performHapticFeedback(type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
.launchIn(this)
|
||||
}
|
||||
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
// dashboard
|
||||
Canvas(
|
||||
Modifier
|
||||
.wrapContentSize(Alignment.Center)
|
||||
.requiredSize(RemoteDirectionControllerSize)
|
||||
.draggable2D(
|
||||
state = draggable2DState,
|
||||
enabled = !pressed,
|
||||
onDragStopped = {
|
||||
draggedPosition = Offset.Unspecified
|
||||
}
|
||||
)
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
enabled = !handled,
|
||||
indication = null,
|
||||
onClick = {}
|
||||
)
|
||||
.graphicsLayer {
|
||||
rotationX = currentDashboardRotationX
|
||||
rotationY = currentDashboardRotationY
|
||||
}
|
||||
.then(modifier)
|
||||
) {
|
||||
val radius = size.minDimension / 2
|
||||
val safeDragRadius = radius * safeDragThreshold
|
||||
val safePressRadius = radius * safePressThreshold
|
||||
|
||||
when {
|
||||
pressedPosition.isSpecified -> {
|
||||
val merged = pressedPosition - center
|
||||
val tan = merged.x / merged.y
|
||||
// +1(--) 0 -1(+-)
|
||||
// gigantic gigantic
|
||||
// -1(-+) 0 +1(++)
|
||||
direction = when {
|
||||
merged.getDistance() < safePressRadius -> RemoteDirection.ENTER
|
||||
merged.x < 0f && (tan > 1f || tan < -1f) -> RemoteDirection.LEFT
|
||||
merged.x > 0f && (tan > 1f || tan < -1f) -> RemoteDirection.RIGHT
|
||||
merged.y < 0f && tan in -1f..1f -> RemoteDirection.UP
|
||||
merged.y > 0f && tan in -1f..1f -> RemoteDirection.DOWN
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
draggedPosition.isSpecified -> {
|
||||
val merged = draggedPosition - center
|
||||
val tan = merged.x / merged.y
|
||||
// +1(--) 0 -1(+-)
|
||||
// gigantic gigantic
|
||||
// -1(-+) 0 +1(++)
|
||||
direction = when {
|
||||
merged.getDistance() < safeDragRadius -> null
|
||||
merged.x < 0f && (tan > 1f || tan < -1f) -> RemoteDirection.LEFT
|
||||
merged.x > 0f && (tan > 1f || tan < -1f) -> RemoteDirection.RIGHT
|
||||
merged.y < 0f && tan in -1f..1f -> RemoteDirection.UP
|
||||
merged.y > 0f && tan in -1f..1f -> RemoteDirection.DOWN
|
||||
else -> null
|
||||
}
|
||||
val xNeg = if (merged.y > 0) -1f else 1f
|
||||
val yNeg = if (merged.x < 0) -1f else 1f
|
||||
currentDashboardRotationX = merged.copy(x = 0f)
|
||||
.getDistance() / radius * 15 * xNeg
|
||||
currentDashboardRotationY = merged.copy(y = 0f)
|
||||
.getDistance() / radius * 15 * yNeg
|
||||
|
||||
if (!handled && merged.getDistance() > safeDragRadius) {
|
||||
handled = true
|
||||
direction?.let {
|
||||
onRemoteDirection(it)
|
||||
hapticFeedbackType?.let { type ->
|
||||
hapticFeedback.performHapticFeedback(type)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
when {
|
||||
pressedPosition.isUnspecified && draggedPosition.isUnspecified -> {
|
||||
direction = null
|
||||
currentDashboardRotationX = 0f
|
||||
currentDashboardRotationY = 0f
|
||||
pressed = false
|
||||
handled = false
|
||||
pressedPosition = center
|
||||
draggedPosition = center
|
||||
}
|
||||
|
||||
draggedPosition.isUnspecified -> {
|
||||
handled = false
|
||||
draggedPosition = center
|
||||
}
|
||||
|
||||
pressedPosition.isUnspecified -> {
|
||||
pressed = false
|
||||
pressedPosition = center
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
drawCircle(
|
||||
color = currentContainerColor,
|
||||
radius = radius
|
||||
)
|
||||
}
|
||||
// handle
|
||||
Canvas(Modifier.matchParentSize()) {
|
||||
val radius = size.minDimension / 2
|
||||
drawCircle(
|
||||
color = contentColor.copy(currentHandleAlpha),
|
||||
radius = radius * currentHandleZoom,
|
||||
center = currentPosition.takeOrElse { center }
|
||||
)
|
||||
}
|
||||
val icon = when (direction) {
|
||||
RemoteDirection.LEFT -> Icons.AutoMirrored.Rounded.KeyboardArrowLeft
|
||||
RemoteDirection.RIGHT -> Icons.AutoMirrored.Rounded.KeyboardArrowRight
|
||||
RemoteDirection.UP -> Icons.Rounded.KeyboardArrowUp
|
||||
RemoteDirection.DOWN -> Icons.Rounded.KeyboardArrowDown
|
||||
else -> null
|
||||
}
|
||||
// icon
|
||||
AnimatedVisibility(
|
||||
visible = handled || pressed,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.65f),
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.65f),
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.graphicsLayer {
|
||||
rotationX = currentDashboardRotationX
|
||||
rotationY = currentDashboardRotationY
|
||||
}
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = direction?.name,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.matchParentSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val RemoteDirectionControllerSize = 256.dp
|
@ -1,177 +0,0 @@
|
||||
package com.m3u.smartphone.ui.common.connect
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.ArrowBackIosNew
|
||||
import androidx.compose.material.icons.rounded.Delete
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
internal fun VirtualNumberKeyboard(
|
||||
modifier: Modifier = Modifier,
|
||||
code: String,
|
||||
onCode: (String) -> Unit,
|
||||
) {
|
||||
Column(
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(.1f))
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
) {
|
||||
KeyboardKey(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = "1",
|
||||
onClick = { if (code.length < 6) onCode(code + "1") }
|
||||
)
|
||||
KeyboardKey(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = "2",
|
||||
onClick = { if (code.length < 6) onCode(code + "2") }
|
||||
)
|
||||
KeyboardKey(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = "3",
|
||||
onClick = { if (code.length < 6) onCode(code + "3") }
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
) {
|
||||
KeyboardKey(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = "4",
|
||||
onClick = { if (code.length < 6) onCode(code + "4") }
|
||||
)
|
||||
KeyboardKey(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = "5",
|
||||
onClick = { if (code.length < 6) onCode(code + "5") }
|
||||
)
|
||||
KeyboardKey(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = "6",
|
||||
onClick = { if (code.length < 6) onCode(code + "6") }
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
) {
|
||||
KeyboardKey(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = "7",
|
||||
onClick = { if (code.length < 6) onCode(code + "7") }
|
||||
)
|
||||
KeyboardKey(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = "8",
|
||||
onClick = { if (code.length < 6) onCode(code + "8") }
|
||||
)
|
||||
KeyboardKey(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = "9",
|
||||
onClick = { if (code.length < 6) onCode(code + "9") }
|
||||
)
|
||||
}
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.height(54.dp)
|
||||
.weight(1f)
|
||||
.clickable(
|
||||
onClick = {
|
||||
if (code.isNotEmpty()) {
|
||||
onCode(
|
||||
code.substring(0, code.length - 1)
|
||||
)
|
||||
}
|
||||
},
|
||||
indication = ripple(color = MaterialTheme.colorScheme.primary),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(38.dp),
|
||||
imageVector = Icons.Rounded.ArrowBackIosNew,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
KeyboardKey(
|
||||
modifier = Modifier.weight(1f),
|
||||
text = "0",
|
||||
onClick = { if (code.length < 6) onCode(code + "0") }
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.height(54.dp)
|
||||
.weight(1f)
|
||||
.clickable(
|
||||
onClick = {
|
||||
if (code.isNotEmpty()) {
|
||||
onCode("")
|
||||
}
|
||||
},
|
||||
indication = ripple(color = MaterialTheme.colorScheme.primary),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(38.dp),
|
||||
imageVector = Icons.Rounded.Delete,
|
||||
contentDescription = "Delete"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyboardKey(
|
||||
modifier: Modifier,
|
||||
text: String,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
TextButton(
|
||||
modifier = modifier.height(54.dp),
|
||||
onClick = onClick,
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
contentPadding = PaddingValues(0.dp)
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(.8f)
|
||||
)
|
||||
}
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
package com.m3u.smartphone.ui.material.components
|
||||
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.focus.FocusDirection
|
||||
import com.m3u.data.tv.model.RemoteDirection
|
||||
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
fun RemoteDirection.asFocusDirection(): FocusDirection {
|
||||
return when (this) {
|
||||
RemoteDirection.LEFT -> FocusDirection.Left
|
||||
RemoteDirection.RIGHT -> FocusDirection.Right
|
||||
RemoteDirection.UP -> FocusDirection.Up
|
||||
RemoteDirection.DOWN -> FocusDirection.Down
|
||||
RemoteDirection.ENTER -> FocusDirection.Enter
|
||||
RemoteDirection.EXIT -> FocusDirection.Exit
|
||||
}
|
||||
}
|
@ -4,7 +4,6 @@ import android.app.Application
|
||||
import android.os.Build
|
||||
import com.m3u.core.architecture.Abi
|
||||
import com.m3u.core.architecture.Publisher
|
||||
import com.m3u.core.util.context.tv
|
||||
import javax.inject.Inject
|
||||
|
||||
class AppPublisher @Inject constructor(private val application: Application) : Publisher {
|
||||
@ -12,10 +11,6 @@ class AppPublisher @Inject constructor(private val application: Application) : P
|
||||
override val versionName: String = BuildConfig.VERSION_NAME
|
||||
override val versionCode: Int = BuildConfig.VERSION_CODE
|
||||
override val debug: Boolean = BuildConfig.DEBUG
|
||||
override val snapshot: Boolean = false
|
||||
override val lite: Boolean = false
|
||||
override val model: String = Build.MODEL
|
||||
override val abi: Abi = Abi.of(Build.SUPPORTED_ABIS[0])
|
||||
override val tv: Boolean
|
||||
get() = application.resources.configuration.tv
|
||||
}
|
@ -128,7 +128,19 @@ fun FeaturedSpecsCarousel(
|
||||
}
|
||||
is Recommend.DiscoverSpec -> TODO()
|
||||
is Recommend.NewRelease -> TODO()
|
||||
is Recommend.CwSpec -> TODO()
|
||||
is Recommend.CwSpec -> {
|
||||
// background
|
||||
CarouselItemBackground(
|
||||
channel = spec.channel,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
// foreground
|
||||
CarouselItemForeground(
|
||||
channel = spec.channel,
|
||||
isCarouselFocused = isCarouselFocused,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
@ -49,12 +49,9 @@ baselineProfile {
|
||||
|
||||
androidComponents {
|
||||
onVariants { v ->
|
||||
val isSnapshot = "snapshot" in v.name
|
||||
|
||||
v.instrumentationRunnerArguments.put(
|
||||
"targetAppId",
|
||||
if (isSnapshot) "com.m3u.smartphone.snapshot"
|
||||
else "com.m3u.smartphone"
|
||||
"com.m3u.smartphone"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -20,7 +20,6 @@ import com.m3u.core.architecture.preferences.Settings
|
||||
import com.m3u.core.architecture.preferences.flowOf
|
||||
import com.m3u.core.architecture.preferences.set
|
||||
import com.m3u.core.util.basic.startWithHttpScheme
|
||||
import com.m3u.data.api.TvApiDelegate
|
||||
import com.m3u.data.database.dao.ColorSchemeDao
|
||||
import com.m3u.data.database.example.ColorSchemeExample
|
||||
import com.m3u.data.database.model.Channel
|
||||
@ -55,7 +54,6 @@ class SettingViewModel @Inject constructor(
|
||||
private val workManager: WorkManager,
|
||||
private val settings: Settings,
|
||||
private val messager: Messager,
|
||||
private val tvApi: TvApiDelegate,
|
||||
publisher: Publisher,
|
||||
// FIXME: do not use dao in viewmodel
|
||||
private val colorSchemeDao: ColorSchemeDao,
|
||||
@ -160,20 +158,6 @@ class SettingViewModel @Inject constructor(
|
||||
else "http://$inputBasicUrl"
|
||||
|
||||
when {
|
||||
forTvState.value -> {
|
||||
viewModelScope.launch {
|
||||
tvApi.subscribe(
|
||||
title,
|
||||
urlOrUri,
|
||||
basicUrl,
|
||||
username,
|
||||
password,
|
||||
epg.ifBlank { null },
|
||||
selected
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> when (selected) {
|
||||
DataSource.M3U -> {
|
||||
if (title.isEmpty()) {
|
||||
@ -342,7 +326,6 @@ class SettingViewModel @Inject constructor(
|
||||
val urlState = mutableStateOf("")
|
||||
val uriState = mutableStateOf(Uri.EMPTY)
|
||||
val localStorageState = mutableStateOf(false)
|
||||
val forTvState = mutableStateOf(false)
|
||||
val basicUrlState = mutableStateOf("")
|
||||
val usernameState = mutableStateOf("")
|
||||
val passwordState = mutableStateOf("")
|
||||
|
@ -8,11 +8,8 @@ interface Publisher {
|
||||
val versionName: String
|
||||
val versionCode: Int
|
||||
val debug: Boolean
|
||||
val snapshot: Boolean
|
||||
val lite: Boolean
|
||||
val model: String
|
||||
val abi: Abi
|
||||
val tv: Boolean
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
|
@ -7,9 +7,3 @@ val Configuration.isPortraitMode: Boolean
|
||||
|
||||
val Configuration.isDarkMode: Boolean
|
||||
get() = uiMode == Configuration.UI_MODE_NIGHT_YES
|
||||
|
||||
val Configuration.tv: Boolean
|
||||
get() {
|
||||
val type = uiMode and Configuration.UI_MODE_TYPE_MASK
|
||||
return type == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
@ -1,4 +1,5 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.m3u.data.api
|
||||
|
||||
import android.content.Context
|
||||
@ -20,7 +21,6 @@ import okhttp3.Protocol
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import javax.inject.Qualifier
|
||||
import javax.inject.Singleton
|
||||
|
||||
@ -61,18 +61,7 @@ internal object ApiModule {
|
||||
.addInterceptor { chain ->
|
||||
val request = chain.request()
|
||||
try {
|
||||
chain.proceed(request).apply {
|
||||
val body = body
|
||||
if (body != null) {
|
||||
val contentType = body.contentType()
|
||||
val isWebPage = contentType != null &&
|
||||
contentType.type == "text" &&
|
||||
contentType.subtype == "html"
|
||||
if (isWebPage) {
|
||||
WebPageManager.push(body)
|
||||
}
|
||||
}
|
||||
}
|
||||
chain.proceed(request)
|
||||
} catch (e: Exception) {
|
||||
logger.log(e)
|
||||
Response.Builder()
|
||||
@ -103,12 +92,4 @@ internal object ApiModule {
|
||||
.addConverterFactory(json.asConverterFactory(mediaType))
|
||||
.client(okHttpClient)
|
||||
}
|
||||
|
||||
@Provides
|
||||
fun provideGithubApi(
|
||||
builder: Retrofit.Builder
|
||||
): GithubApi = builder
|
||||
.baseUrl(BaseUrls.GITHUB_BASE_URL)
|
||||
.build()
|
||||
.create()
|
||||
}
|
||||
|
@ -1,37 +0,0 @@
|
||||
@file:Suppress("unused")
|
||||
package com.m3u.data.api
|
||||
|
||||
import com.m3u.data.api.dto.github.File
|
||||
import com.m3u.data.api.dto.github.Release
|
||||
import com.m3u.data.api.dto.github.Tree
|
||||
import com.m3u.data.api.dto.github.User
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Path
|
||||
|
||||
interface GithubApi {
|
||||
@GET("/repos/{author}/{repos}/releases")
|
||||
suspend fun releases(
|
||||
@Path("author") author: String,
|
||||
@Path("repos") repos: String
|
||||
): List<Release>
|
||||
|
||||
@GET("/repos/{author}/{repos}/contents/{subpath}")
|
||||
suspend fun contents(
|
||||
@Path("author") author: String,
|
||||
@Path("repos") repos: String,
|
||||
@Path("subpath") subpath: String = ""
|
||||
): File?
|
||||
|
||||
@GET("/repos/{author}/{repos}/trees/{sha}")
|
||||
suspend fun tree(
|
||||
@Path("author") author: String,
|
||||
@Path("repos") repos: String,
|
||||
@Path("sha") sha: String
|
||||
): Tree?
|
||||
|
||||
@GET("/repos/{author}/{repos}/contributors")
|
||||
suspend fun contributors(
|
||||
@Path("author") author: String,
|
||||
@Path("repos") repos: String
|
||||
): List<User>
|
||||
}
|
@ -1,137 +0,0 @@
|
||||
package com.m3u.data.api
|
||||
|
||||
import com.m3u.core.architecture.Publisher
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.core.architecture.logger.execute
|
||||
import com.m3u.data.database.model.DataSource
|
||||
import com.m3u.data.tv.http.endpoint.DefRep
|
||||
import com.m3u.data.tv.model.TvInfo
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.create
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.POST
|
||||
import retrofit2.http.Path
|
||||
import retrofit2.http.Query
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
interface TvApi {
|
||||
@GET("/say_hello")
|
||||
suspend fun sayHello(): TvInfo?
|
||||
|
||||
@POST("/playlists/subscribe")
|
||||
suspend fun subscribe(
|
||||
@Query("title") title: String,
|
||||
@Query("url") url: String,
|
||||
@Query("address") basicUrl: String,
|
||||
@Query("username") username: String,
|
||||
@Query("password") password: String,
|
||||
@Query("epg") epg: String?,
|
||||
@Query("data_source") dataSource: DataSource
|
||||
): DefRep?
|
||||
|
||||
@POST("/remotes/{direction}")
|
||||
suspend fun remoteDirection(@Path("direction") remoteDirectionValue: Int): DefRep?
|
||||
}
|
||||
|
||||
@Singleton
|
||||
class TvApiDelegate @Inject constructor(
|
||||
private val builder: Retrofit.Builder,
|
||||
@OkhttpClient(true) private val okHttpClient: OkHttpClient,
|
||||
@Logger.MessageImpl private val logger: Logger,
|
||||
private val publisher: Publisher,
|
||||
): TvApi {
|
||||
fun prepare(host: String, port: Int): Flow<TvInfo> = callbackFlow {
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
val baseUrl = HttpUrl.Builder()
|
||||
.scheme("http")
|
||||
.host(host)
|
||||
.port(port)
|
||||
.build()
|
||||
|
||||
api = builder
|
||||
.baseUrl(baseUrl)
|
||||
.build()
|
||||
.create()
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(
|
||||
baseUrl
|
||||
.newBuilder("say_hello")!!
|
||||
.addQueryParameter("model", publisher.model)
|
||||
.build()
|
||||
)
|
||||
.build()
|
||||
|
||||
val listener = object : WebSocketListener() {
|
||||
override fun onMessage(webSocket: WebSocket, text: String) {
|
||||
try {
|
||||
val info = json.decodeFromString<TvInfo?>(text) ?: return
|
||||
trySendBlocking(info)
|
||||
} catch (e: IllegalStateException) {
|
||||
logger.log(e.message.orEmpty())
|
||||
cancel()
|
||||
} catch (e: Exception) {
|
||||
logger.log(e.message.orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
channel.close()
|
||||
}
|
||||
}
|
||||
val webSocket = okHttpClient.newWebSocket(request, listener)
|
||||
awaitClose {
|
||||
api = null
|
||||
webSocket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
api = null
|
||||
}
|
||||
|
||||
override suspend fun sayHello(): TvInfo? = logger.execute {
|
||||
requireApi().sayHello()
|
||||
}
|
||||
|
||||
override suspend fun subscribe(
|
||||
title: String,
|
||||
url: String,
|
||||
basicUrl: String,
|
||||
username: String,
|
||||
password: String,
|
||||
epg: String?,
|
||||
dataSource: DataSource
|
||||
): DefRep? = logger.execute {
|
||||
requireApi().subscribe(title, url, basicUrl, username, password, epg, dataSource)
|
||||
}
|
||||
|
||||
override suspend fun remoteDirection(remoteDirectionValue: Int): DefRep? = logger.execute {
|
||||
requireApi().remoteDirection(remoteDirectionValue)
|
||||
}
|
||||
|
||||
private var api: TvApi? = null
|
||||
private fun requireApi(): TvApi =
|
||||
checkNotNull(api) { "You haven't connected tv" }
|
||||
|
||||
private fun checkCompatibleInfoOrThrow(info: TvInfo): TvInfo {
|
||||
check(info.version == publisher.versionCode) {
|
||||
"The software version is incompatible, Please make sure the version is consistent"
|
||||
}
|
||||
return info
|
||||
}
|
||||
}
|
@ -1,45 +0,0 @@
|
||||
package com.m3u.data.api
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.flowWithLifecycle
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import okhttp3.ResponseBody
|
||||
|
||||
object WebPageManager {
|
||||
private val source: MutableSharedFlow<ResponseBody> = MutableSharedFlow()
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob())
|
||||
fun push(body: ResponseBody) {
|
||||
source.tryEmit(body)
|
||||
}
|
||||
|
||||
private val jobs = mutableListOf<Job>()
|
||||
|
||||
fun observe(block: (ResponseBody) -> Unit) {
|
||||
jobs += source
|
||||
.onEach { block(it) }
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
fun observe(lifecycle: Lifecycle, block: (ResponseBody) -> Unit) {
|
||||
jobs += source
|
||||
.onEach { block(it) }
|
||||
.flowWithLifecycle(lifecycle)
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
fun removeAllObservers() {
|
||||
val iterator = jobs.listIterator()
|
||||
while (iterator.hasNext()) {
|
||||
val job = iterator.next()
|
||||
if (!job.isCompleted) {
|
||||
job.cancel()
|
||||
}
|
||||
iterator.remove()
|
||||
}
|
||||
}
|
||||
}
|
@ -1,18 +1,15 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.m3u.data.repository
|
||||
|
||||
import com.m3u.data.repository.media.MediaRepository
|
||||
import com.m3u.data.repository.playlist.PlaylistRepository
|
||||
import com.m3u.data.repository.programme.ProgrammeRepository
|
||||
import com.m3u.data.repository.channel.ChannelRepository
|
||||
import com.m3u.data.repository.tv.TvRepository
|
||||
import com.m3u.data.repository.media.MediaRepositoryImpl
|
||||
import com.m3u.data.repository.playlist.PlaylistRepositoryImpl
|
||||
import com.m3u.data.repository.programme.ProgrammeRepositoryImpl
|
||||
import com.m3u.data.repository.channel.ChannelRepositoryImpl
|
||||
import com.m3u.data.repository.other.OtherRepository
|
||||
import com.m3u.data.repository.other.OtherRepositoryImpl
|
||||
import com.m3u.data.repository.tv.TvRepositoryImpl
|
||||
import com.m3u.data.repository.media.MediaRepository
|
||||
import com.m3u.data.repository.media.MediaRepositoryImpl
|
||||
import com.m3u.data.repository.playlist.PlaylistRepository
|
||||
import com.m3u.data.repository.playlist.PlaylistRepositoryImpl
|
||||
import com.m3u.data.repository.programme.ProgrammeRepository
|
||||
import com.m3u.data.repository.programme.ProgrammeRepositoryImpl
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
@ -45,16 +42,4 @@ internal interface RepositoryModule {
|
||||
fun bindMediaRepository(
|
||||
repository: MediaRepositoryImpl
|
||||
): MediaRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindTvRepository(
|
||||
repository: TvRepositoryImpl
|
||||
): TvRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindOtherRepository(
|
||||
repositoryImpl: OtherRepositoryImpl
|
||||
): OtherRepository
|
||||
}
|
||||
|
@ -1,7 +0,0 @@
|
||||
package com.m3u.data.repository.other
|
||||
|
||||
import com.m3u.data.api.dto.github.Release
|
||||
|
||||
interface OtherRepository {
|
||||
suspend fun release(): Release?
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
package com.m3u.data.repository.other
|
||||
|
||||
import com.m3u.core.architecture.Publisher
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.core.architecture.logger.Profiles
|
||||
import com.m3u.core.architecture.logger.execute
|
||||
import com.m3u.core.architecture.logger.install
|
||||
import com.m3u.core.util.collections.indexOf
|
||||
import com.m3u.data.api.GithubApi
|
||||
import com.m3u.data.api.dto.github.Release
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class OtherRepositoryImpl @Inject constructor(
|
||||
private val githubApi: GithubApi,
|
||||
private val publisher: Publisher,
|
||||
delegate: Logger
|
||||
) : OtherRepository {
|
||||
private val logger = delegate.install(Profiles.REPOS_OTHER)
|
||||
private var releases: List<Release>? = null
|
||||
override suspend fun release(): Release? {
|
||||
if (releases == null) {
|
||||
// cannot use lazy because the suspension
|
||||
releases = logger.execute { githubApi.releases("oxyroid", "M3UAndroid") } ?: emptyList()
|
||||
}
|
||||
val versionName = publisher.versionName
|
||||
val currentReleases = releases ?: emptyList()
|
||||
if (currentReleases.isEmpty()) return null
|
||||
val i = currentReleases.indexOf { it.name == versionName }
|
||||
if (i <= 0 && !publisher.snapshot) return null
|
||||
return currentReleases.first()
|
||||
}
|
||||
}
|
@ -1,31 +0,0 @@
|
||||
package com.m3u.data.repository.tv
|
||||
|
||||
import com.m3u.data.tv.model.TvInfo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
abstract class TvRepository {
|
||||
abstract val broadcastCodeOnTv: StateFlow<Int?>
|
||||
|
||||
protected abstract fun broadcastOnTv()
|
||||
protected abstract fun closeBroadcastOnTv()
|
||||
|
||||
abstract val connected: StateFlow<TvInfo?>
|
||||
|
||||
abstract fun connectToTv(
|
||||
broadcastCode: Int,
|
||||
timeout: Duration = 8.seconds
|
||||
): Flow<ConnectionToTvValue>
|
||||
|
||||
abstract suspend fun disconnectToTv()
|
||||
}
|
||||
|
||||
sealed interface ConnectionToTvValue {
|
||||
data class Idle(val reason: String? = null) : ConnectionToTvValue
|
||||
data object Searching : ConnectionToTvValue
|
||||
data object Connecting : ConnectionToTvValue
|
||||
data object Timeout : ConnectionToTvValue
|
||||
data class Completed(val host: String, val port: Int) : ConnectionToTvValue
|
||||
}
|
@ -1,187 +0,0 @@
|
||||
package com.m3u.data.repository.tv
|
||||
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import com.m3u.core.architecture.Publisher
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.core.architecture.logger.Profiles
|
||||
import com.m3u.core.architecture.logger.install
|
||||
import com.m3u.core.architecture.preferences.PreferencesKeys
|
||||
import com.m3u.core.architecture.preferences.Settings
|
||||
import com.m3u.core.architecture.preferences.flowOf
|
||||
import com.m3u.core.util.coroutine.timeout
|
||||
import com.m3u.core.wrapper.Resource
|
||||
import com.m3u.core.wrapper.asResource
|
||||
import com.m3u.data.api.TvApiDelegate
|
||||
import com.m3u.data.tv.Utils
|
||||
import com.m3u.data.tv.http.HttpServer
|
||||
import com.m3u.data.tv.model.TvInfo
|
||||
import com.m3u.data.tv.nsd.NsdDeviceManager
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.channelFlow
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
import kotlinx.coroutines.flow.onCompletion
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
||||
class TvRepositoryImpl @Inject constructor(
|
||||
private val nsdDeviceManager: NsdDeviceManager,
|
||||
private val httpServer: HttpServer,
|
||||
private val tvApi: TvApiDelegate,
|
||||
logger: Logger,
|
||||
settings: Settings,
|
||||
publisher: Publisher,
|
||||
) : TvRepository() {
|
||||
private val logger = logger.install(Profiles.REPOS_LEANBACK)
|
||||
private val tv = publisher.tv
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob())
|
||||
|
||||
init {
|
||||
settings
|
||||
.flowOf(PreferencesKeys.REMOTE_CONTROL)
|
||||
.onEach { remoteControl ->
|
||||
when {
|
||||
!remoteControl -> closeBroadcastOnTv()
|
||||
tv -> broadcastOnTv()
|
||||
else -> closeBroadcastOnTv()
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
private val _broadcastCodeOnTv = MutableStateFlow<Int?>(null)
|
||||
override val broadcastCodeOnTv = _broadcastCodeOnTv.asStateFlow()
|
||||
|
||||
private var broadcastOnTvJob: Job? = null
|
||||
|
||||
override fun broadcastOnTv() {
|
||||
val serverPort = Utils.findPort()
|
||||
closeBroadcastOnTv()
|
||||
httpServer.start(serverPort)
|
||||
broadcastOnTvJob = coroutineScope.launch {
|
||||
while (isActive) {
|
||||
val nsdPort = Utils.findPort()
|
||||
val pin = Utils.createPin()
|
||||
val host = Utils.getLocalHostAddress() ?: continue
|
||||
|
||||
logger.log("start-server: server-port[$serverPort], nsd-port[$nsdPort], pin[$pin], host[$host]")
|
||||
|
||||
nsdDeviceManager
|
||||
.broadcast(
|
||||
pin = pin,
|
||||
port = nsdPort,
|
||||
metadata = mapOf(
|
||||
NsdDeviceManager.META_DATA_PORT to serverPort,
|
||||
NsdDeviceManager.META_DATA_HOST to host
|
||||
)
|
||||
)
|
||||
.onStart {
|
||||
logger.log("start-server: opening...")
|
||||
}
|
||||
.onCompletion {
|
||||
_broadcastCodeOnTv.value = null
|
||||
logger.log("start-server: nsd completed")
|
||||
}
|
||||
.onEach { registered ->
|
||||
logger.log("start-server: registered: $registered")
|
||||
_broadcastCodeOnTv.value = if (registered != null) pin else null
|
||||
}
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun closeBroadcastOnTv() {
|
||||
httpServer.stop()
|
||||
broadcastOnTvJob?.cancel()
|
||||
broadcastOnTvJob = null
|
||||
}
|
||||
|
||||
private val _connected = MutableStateFlow<TvInfo?>(null)
|
||||
override val connected: StateFlow<TvInfo?> = _connected.asStateFlow()
|
||||
|
||||
private var connectToTvJob: Job? = null
|
||||
|
||||
override fun connectToTv(
|
||||
broadcastCode: Int,
|
||||
timeout: Duration
|
||||
): Flow<ConnectionToTvValue> = channelFlow {
|
||||
val completed = nsdDeviceManager
|
||||
.search()
|
||||
.onStart { trySendBlocking(ConnectionToTvValue.Searching) }
|
||||
.timeout(timeout) {
|
||||
logger.log("pair: timeout")
|
||||
trySendBlocking(ConnectionToTvValue.Timeout)
|
||||
}
|
||||
.mapNotNull { all ->
|
||||
logger.log("pair: all devices: $all")
|
||||
val info = all.find {
|
||||
it.getAttribute(NsdDeviceManager.META_DATA_PIN) == broadcastCode.toString()
|
||||
} ?: return@mapNotNull null
|
||||
val port = info
|
||||
.getAttribute(NsdDeviceManager.META_DATA_PORT)
|
||||
?.toIntOrNull()
|
||||
?: return@mapNotNull null
|
||||
val host = info
|
||||
.getAttribute(NsdDeviceManager.META_DATA_HOST)
|
||||
?: return@mapNotNull null
|
||||
|
||||
ConnectionToTvValue.Completed(host, port)
|
||||
}
|
||||
.firstOrNull()
|
||||
|
||||
if (completed != null) {
|
||||
trySendBlocking(ConnectionToTvValue.Connecting)
|
||||
connectToTvJob?.cancel()
|
||||
connectToTvJob = tvApi
|
||||
.prepare(completed.host, completed.port)
|
||||
.asResource()
|
||||
.onEach { resource ->
|
||||
when (resource) {
|
||||
Resource.Loading -> {
|
||||
logger.log("pair: connecting")
|
||||
}
|
||||
|
||||
is Resource.Success -> {
|
||||
logger.log("pair: connected")
|
||||
_connected.value = resource.data
|
||||
trySendBlocking(completed)
|
||||
}
|
||||
|
||||
is Resource.Failure -> {
|
||||
logger.log("pair: catch an error, ${resource.message}")
|
||||
_connected.value = null
|
||||
trySendBlocking(ConnectionToTvValue.Idle(resource.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
awaitClose {
|
||||
trySendBlocking(ConnectionToTvValue.Idle())
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disconnectToTv() {
|
||||
connectToTvJob?.cancel()
|
||||
connectToTvJob = null
|
||||
_connected.value = null
|
||||
}
|
||||
|
||||
private fun NsdServiceInfo.getAttribute(key: String): String? =
|
||||
attributes[key]?.decodeToString()
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
package com.m3u.data.service
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.m3u.data.tv.model.RemoteDirection
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
|
||||
@Immutable
|
||||
interface DPadReactionService {
|
||||
val incoming: SharedFlow<RemoteDirection>
|
||||
suspend fun emit(remoteDirection: RemoteDirection)
|
||||
}
|
@ -20,14 +20,9 @@ import com.m3u.core.architecture.preferences.Settings
|
||||
import com.m3u.core.architecture.preferences.settings
|
||||
import com.m3u.data.logger.MessageLogger
|
||||
import com.m3u.data.logger.StubLogger
|
||||
import com.m3u.data.service.internal.DPadReactionServiceImpl
|
||||
import com.m3u.data.service.internal.FileProviderImpl
|
||||
import com.m3u.data.service.internal.MessagerImpl
|
||||
import com.m3u.data.service.internal.PlayerManagerImpl
|
||||
import com.m3u.data.tv.http.HttpServer
|
||||
import com.m3u.data.tv.http.HttpServerImpl
|
||||
import com.m3u.data.tv.nsd.NsdDeviceManager
|
||||
import com.m3u.data.tv.nsd.NsdDeviceManagerImpl
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
@ -61,18 +56,6 @@ internal interface BindServicesModule {
|
||||
@Singleton
|
||||
@Logger.MessageImpl
|
||||
fun bindMessageLogger(logger: MessageLogger): Logger
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindNsdDeviceManager(manager: NsdDeviceManagerImpl): NsdDeviceManager
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindHttpServer(server: HttpServerImpl): HttpServer
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindDPadReactionService(service: DPadReactionServiceImpl): DPadReactionService
|
||||
}
|
||||
|
||||
@Module
|
||||
|
@ -1,16 +0,0 @@
|
||||
package com.m3u.data.service.internal
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.m3u.data.service.DPadReactionService
|
||||
import com.m3u.data.tv.model.RemoteDirection
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
@Immutable
|
||||
class DPadReactionServiceImpl @Inject constructor() : DPadReactionService {
|
||||
override val incoming = MutableSharedFlow<RemoteDirection>()
|
||||
|
||||
override suspend fun emit(remoteDirection: RemoteDirection) {
|
||||
incoming.emit(remoteDirection)
|
||||
}
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
package com.m3u.data.tv
|
||||
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.ServerSocket
|
||||
import kotlin.random.Random
|
||||
|
||||
internal object Utils {
|
||||
fun findPort(): Int = ServerSocket(0).use { it.localPort }
|
||||
fun getLocalHostAddress(): String? = NetworkInterface
|
||||
.getNetworkInterfaces()
|
||||
.iterator()
|
||||
.asSequence()
|
||||
.flatMap { networkInterface ->
|
||||
networkInterface.inetAddresses
|
||||
.asSequence()
|
||||
.filter { !it.isLoopbackAddress }
|
||||
}
|
||||
.toList()
|
||||
.filter { it.isLocalAddress() }
|
||||
.map { it.hostAddress }
|
||||
.firstOrNull()
|
||||
|
||||
fun createPin(): Int = Random.nextInt(999999)
|
||||
private fun InetAddress.isLocalAddress(): Boolean {
|
||||
try {
|
||||
return isSiteLocalAddress
|
||||
&& !hostAddress!!.contains(":")
|
||||
&& hostAddress != "127.0.0.1"
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
@ -1,6 +0,0 @@
|
||||
package com.m3u.data.tv.http
|
||||
|
||||
interface HttpServer {
|
||||
fun start(port: Int)
|
||||
fun stop()
|
||||
}
|
@ -1,79 +0,0 @@
|
||||
package com.m3u.data.tv.http
|
||||
|
||||
import com.m3u.data.tv.http.endpoint.Playlists
|
||||
import com.m3u.data.tv.http.endpoint.Remotes
|
||||
import com.m3u.data.tv.http.endpoint.SayHellos
|
||||
import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import io.ktor.server.application.Application
|
||||
import io.ktor.server.application.install
|
||||
import io.ktor.server.engine.EmbeddedServer
|
||||
import io.ktor.server.engine.embeddedServer
|
||||
import io.ktor.server.netty.Netty
|
||||
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.server.plugins.cors.routing.CORS
|
||||
import io.ktor.server.routing.routing
|
||||
import io.ktor.server.websocket.WebSockets
|
||||
import io.ktor.server.websocket.pingPeriod
|
||||
import io.ktor.server.websocket.timeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
internal class HttpServerImpl @Inject constructor(
|
||||
private val sayHellos: SayHellos,
|
||||
private val playlists: Playlists,
|
||||
private val remotes: Remotes
|
||||
) : HttpServer {
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
|
||||
override fun start(port: Int) {
|
||||
server = embeddedServer(Netty, port) {
|
||||
configureSerialization()
|
||||
configureSockets()
|
||||
configureCors()
|
||||
routing {
|
||||
sayHellos.apply(this)
|
||||
playlists.apply(this)
|
||||
remotes.apply(this)
|
||||
}
|
||||
}.apply {
|
||||
start(false)
|
||||
}
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
server?.stop()
|
||||
server = null
|
||||
}
|
||||
|
||||
private fun Application.configureSerialization() {
|
||||
install(ContentNegotiation) {
|
||||
json(
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun Application.configureSockets() {
|
||||
install(WebSockets) {
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
}
|
||||
contentConverter = KotlinxWebsocketSerializationConverter(json)
|
||||
pingPeriod = 15.seconds
|
||||
timeout = 15.seconds
|
||||
}
|
||||
}
|
||||
|
||||
private fun Application.configureCors() {
|
||||
install(CORS) {
|
||||
anyHost()
|
||||
allowSameOrigin = true
|
||||
}
|
||||
}
|
||||
}
|
@ -1,11 +0,0 @@
|
||||
package com.m3u.data.tv.http.endpoint
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Keep
|
||||
@Serializable
|
||||
data class DefRep(
|
||||
val result: Boolean,
|
||||
val reason: String? = null
|
||||
)
|
@ -1,7 +0,0 @@
|
||||
package com.m3u.data.tv.http.endpoint
|
||||
|
||||
import io.ktor.server.routing.Route
|
||||
|
||||
sealed interface Endpoint {
|
||||
fun apply(route: Route)
|
||||
}
|
@ -1,99 +0,0 @@
|
||||
package com.m3u.data.tv.http.endpoint
|
||||
|
||||
import android.content.Context
|
||||
import androidx.work.WorkManager
|
||||
import com.m3u.data.database.model.DataSource
|
||||
import com.m3u.data.repository.playlist.PlaylistRepository
|
||||
import com.m3u.data.worker.SubscriptionWorker
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
data class Playlists @Inject constructor(
|
||||
private val workManager: WorkManager,
|
||||
private val playlistRepository: PlaylistRepository,
|
||||
@ApplicationContext private val context: Context
|
||||
) : Endpoint {
|
||||
override fun apply(route: Route) {
|
||||
route.route("/playlists") {
|
||||
post("subscribe") {
|
||||
val dataSourceValue = call.queryParameters["data_source"]
|
||||
val dataSource = dataSourceValue?.let { DataSource.ofOrNull(it) }
|
||||
if (dataSource == null) {
|
||||
call.respond(
|
||||
DefRep(
|
||||
result = false,
|
||||
reason = "DataSource $dataSourceValue is unsupported."
|
||||
)
|
||||
)
|
||||
return@post
|
||||
}
|
||||
|
||||
val title = call.queryParameters["title"]
|
||||
val url = call.queryParameters["url"]
|
||||
val epg = call.queryParameters["epg"]
|
||||
val basicUrl = call.queryParameters["address"]
|
||||
val username = call.queryParameters["username"]
|
||||
val password = call.queryParameters["password"]
|
||||
|
||||
when (dataSource) {
|
||||
DataSource.M3U -> {
|
||||
if (title == null || url == null) {
|
||||
call.respond(
|
||||
DefRep(
|
||||
result = false,
|
||||
reason = "Both title and url are required."
|
||||
)
|
||||
)
|
||||
return@post
|
||||
}
|
||||
SubscriptionWorker.m3u(workManager, title, url)
|
||||
}
|
||||
|
||||
DataSource.Xtream -> {
|
||||
if (title == null || url == null) {
|
||||
call.respond(
|
||||
DefRep(
|
||||
result = false,
|
||||
reason = "Both title and url are required."
|
||||
)
|
||||
)
|
||||
return@post
|
||||
}
|
||||
SubscriptionWorker.xtream(
|
||||
workManager,
|
||||
title,
|
||||
url,
|
||||
basicUrl.orEmpty(),
|
||||
username.orEmpty(),
|
||||
password.orEmpty()
|
||||
)
|
||||
}
|
||||
|
||||
DataSource.EPG -> {
|
||||
if (title == null || epg == null) {
|
||||
call.respond(
|
||||
DefRep(
|
||||
result = false,
|
||||
reason = "Both title and epg link are required."
|
||||
)
|
||||
)
|
||||
return@post
|
||||
}
|
||||
playlistRepository.insertEpgAsPlaylist(title, epg)
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
call.respond(
|
||||
DefRep(result = true)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,44 +0,0 @@
|
||||
package com.m3u.data.tv.http.endpoint
|
||||
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.core.architecture.logger.sandBox
|
||||
import com.m3u.data.service.DPadReactionService
|
||||
import com.m3u.data.tv.model.RemoteDirection
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
data class Remotes @Inject constructor(
|
||||
private val logger: Logger,
|
||||
private val dPadReactionService: DPadReactionService
|
||||
) : Endpoint {
|
||||
override fun apply(route: Route) {
|
||||
route.route("/remotes") {
|
||||
post("{direction?}") {
|
||||
logger.sandBox {
|
||||
val remoteDirection = call
|
||||
.parameters["direction"]
|
||||
?.toInt()
|
||||
?.let { RemoteDirection.of(it) }
|
||||
if (remoteDirection == null) {
|
||||
call.respond(
|
||||
DefRep(
|
||||
result = false,
|
||||
reason = "Remote direction is unsupported."
|
||||
)
|
||||
)
|
||||
return@post
|
||||
}
|
||||
dPadReactionService.emit(remoteDirection)
|
||||
call.respond(
|
||||
DefRep(result = true)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,92 +0,0 @@
|
||||
package com.m3u.data.tv.http.endpoint
|
||||
|
||||
import com.m3u.core.architecture.Publisher
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.core.architecture.logger.sandBox
|
||||
import com.m3u.core.wrapper.Message
|
||||
import com.m3u.data.repository.media.MediaRepository
|
||||
import com.m3u.data.tv.model.TvInfo
|
||||
import io.ktor.server.request.receiveChannel
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.post
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.websocket.sendSerialized
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import io.ktor.websocket.Frame
|
||||
import io.ktor.websocket.readReason
|
||||
import io.ktor.websocket.readText
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
data class SayHellos @Inject constructor(
|
||||
private val publisher: Publisher,
|
||||
@Logger.MessageImpl private val messager: Logger,
|
||||
private val mediaRepository: MediaRepository
|
||||
) : Endpoint {
|
||||
private val info = with(publisher) {
|
||||
TvInfo(
|
||||
model = model,
|
||||
version = versionCode,
|
||||
snapshot = snapshot,
|
||||
abi = abi,
|
||||
allowUpdatedPackage = true
|
||||
)
|
||||
}
|
||||
|
||||
override fun apply(route: Route) {
|
||||
route.route("/say_hello") {
|
||||
get {
|
||||
call.respond(info)
|
||||
}
|
||||
|
||||
post("/install") {
|
||||
messager.sandBox {
|
||||
val version = call.queryParameters["version"]
|
||||
if (version == null) {
|
||||
call.respond(
|
||||
DefRep(
|
||||
result = false,
|
||||
reason = "require version query parameter"
|
||||
)
|
||||
)
|
||||
return@post
|
||||
}
|
||||
mediaRepository.installApk(call.receiveChannel())
|
||||
call.respond(
|
||||
DefRep(true)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
webSocket {
|
||||
val model = call.request.queryParameters["model"] ?: "?"
|
||||
|
||||
messager.log("Connection from [$model]", Message.LEVEL_INFO)
|
||||
sendSerialized(info)
|
||||
|
||||
for (frame in incoming) {
|
||||
when (frame) {
|
||||
is Frame.Text -> {
|
||||
messager.log("[$model] " + frame.readText(), Message.LEVEL_WARN)
|
||||
sendSerialized(info)
|
||||
}
|
||||
|
||||
is Frame.Binary -> {
|
||||
|
||||
}
|
||||
|
||||
is Frame.Close -> {
|
||||
messager.log("Connection lost from [$model], reason: ${frame.readReason()}")
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
@ -1,33 +0,0 @@
|
||||
package com.m3u.data.tv.model
|
||||
|
||||
import android.view.KeyEvent
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
enum class RemoteDirection(val value: Int) {
|
||||
LEFT(0), RIGHT(1), UP(2), DOWN(3), ENTER(4), EXIT(5);
|
||||
|
||||
companion object {
|
||||
fun of(value: Int): RemoteDirection? {
|
||||
return when (value) {
|
||||
0 -> LEFT
|
||||
1 -> RIGHT
|
||||
2 -> UP
|
||||
3 -> DOWN
|
||||
4 -> ENTER
|
||||
5 -> EXIT
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val RemoteDirection.keyCode
|
||||
get() = when (this) {
|
||||
RemoteDirection.LEFT -> KeyEvent.KEYCODE_DPAD_LEFT
|
||||
RemoteDirection.RIGHT -> KeyEvent.KEYCODE_DPAD_RIGHT
|
||||
RemoteDirection.UP -> KeyEvent.KEYCODE_DPAD_UP
|
||||
RemoteDirection.DOWN -> KeyEvent.KEYCODE_DPAD_DOWN
|
||||
RemoteDirection.ENTER -> KeyEvent.KEYCODE_DPAD_CENTER
|
||||
RemoteDirection.EXIT -> KeyEvent.KEYCODE_BACK
|
||||
}
|
@ -1,17 +0,0 @@
|
||||
package com.m3u.data.tv.model
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.m3u.core.architecture.Abi
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Keep
|
||||
@Serializable
|
||||
@Immutable
|
||||
data class TvInfo(
|
||||
val model: String,
|
||||
val version: Int,
|
||||
val snapshot: Boolean,
|
||||
val abi: Abi,
|
||||
val allowUpdatedPackage: Boolean = false
|
||||
)
|
@ -1,21 +0,0 @@
|
||||
package com.m3u.data.tv.nsd
|
||||
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
interface NsdDeviceManager {
|
||||
fun search(): Flow<List<NsdServiceInfo>>
|
||||
fun broadcast(
|
||||
name: String = "M3U_BROADCAST",
|
||||
port: Int,
|
||||
pin: Int,
|
||||
metadata: Map<String, Any> = emptyMap()
|
||||
): Flow<NsdServiceInfo?>
|
||||
|
||||
companion object {
|
||||
const val SERVICE_TYPE = "_m3u-server._tcp."
|
||||
const val META_DATA_PORT = "port"
|
||||
const val META_DATA_HOST = "host"
|
||||
const val META_DATA_PIN = "pin"
|
||||
}
|
||||
}
|
@ -1,136 +0,0 @@
|
||||
package com.m3u.data.tv.nsd
|
||||
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.core.architecture.logger.Profiles
|
||||
import com.m3u.core.architecture.logger.install
|
||||
import com.m3u.data.tv.Utils
|
||||
import com.m3u.data.tv.nsd.NsdDeviceManager.Companion.META_DATA_PIN
|
||||
import com.m3u.data.tv.nsd.NsdDeviceManager.Companion.SERVICE_TYPE
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
class NsdDeviceManagerImpl @Inject constructor(
|
||||
private val nsdManager: NsdManager,
|
||||
delegate: Logger,
|
||||
) : NsdDeviceManager {
|
||||
private val logger = delegate.install(Profiles.SERVICE_NSD)
|
||||
|
||||
override fun search(): Flow<List<NsdServiceInfo>> = callbackFlow<List<NsdServiceInfo>> {
|
||||
logger.log("search")
|
||||
val result = mutableListOf<NsdServiceInfo>()
|
||||
|
||||
val listener = object : NsdManager.DiscoveryListener {
|
||||
override fun onStartDiscoveryFailed(serviceType: String?, errorCode: Int) {
|
||||
logger.log("start discovery failed, error code: $errorCode")
|
||||
}
|
||||
|
||||
override fun onStopDiscoveryFailed(serviceType: String?, errorCode: Int) {
|
||||
logger.log("stop discovery failed, error code: $errorCode")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStarted(serviceType: String?) {
|
||||
trySendBlocking(emptyList())
|
||||
logger.log("discovery started")
|
||||
}
|
||||
|
||||
override fun onDiscoveryStopped(serviceType: String?) {
|
||||
logger.log("discovery stopped")
|
||||
}
|
||||
|
||||
override fun onServiceFound(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType == SERVICE_TYPE) {
|
||||
val listener = NsdResolveListener(
|
||||
onResolved = {
|
||||
if (it != null) {
|
||||
result += it
|
||||
logger.log("service resolve succeed: $serviceInfo")
|
||||
trySendBlocking(result)
|
||||
}
|
||||
},
|
||||
onResolveFailed = {
|
||||
logger.log("service resolve failed")
|
||||
cancel()
|
||||
},
|
||||
onResolvedStopped = {
|
||||
result -= it
|
||||
logger.log("service resolve stopped")
|
||||
trySendBlocking(result)
|
||||
},
|
||||
onStopResolutionFailed = {
|
||||
result -= it
|
||||
logger.log("service stop resolve failed")
|
||||
trySendBlocking(result)
|
||||
}
|
||||
)
|
||||
@Suppress("DEPRECATION")
|
||||
nsdManager.resolveService(serviceInfo, listener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onServiceLost(serviceInfo: NsdServiceInfo) {
|
||||
if (serviceInfo.serviceType == SERVICE_TYPE) {
|
||||
result -= serviceInfo
|
||||
logger.log("service lost: $serviceInfo")
|
||||
trySendBlocking(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
nsdManager.discoverServices(SERVICE_TYPE, NsdManager.PROTOCOL_DNS_SD, listener)
|
||||
awaitClose {
|
||||
nsdManager.stopServiceDiscovery(listener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun broadcast(
|
||||
name: String,
|
||||
port: Int,
|
||||
pin: Int,
|
||||
metadata: Map<String, Any>
|
||||
): Flow<NsdServiceInfo?> = callbackFlow {
|
||||
logger.log("broadcast")
|
||||
val localPort = Utils.findPort()
|
||||
|
||||
val serviceInfo = NsdServiceInfo().apply {
|
||||
serviceName = name
|
||||
serviceType = SERVICE_TYPE
|
||||
setPort(localPort)
|
||||
setAttribute(META_DATA_PIN, pin.toString())
|
||||
metadata.forEach { setAttribute(it.key, it.value.toString()) }
|
||||
}
|
||||
|
||||
val listener = object : NsdManager.RegistrationListener {
|
||||
override fun onServiceRegistered(i: NsdServiceInfo) {
|
||||
trySendBlocking(serviceInfo)
|
||||
logger.log("broadcast registered")
|
||||
}
|
||||
|
||||
override fun onServiceUnregistered(serviceInfo: NsdServiceInfo) {
|
||||
trySendBlocking(null)
|
||||
logger.log("broadcast un-registered")
|
||||
}
|
||||
|
||||
override fun onRegistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
trySendBlocking(null)
|
||||
logger.log("registration failed, error code: $errorCode")
|
||||
cancel()
|
||||
}
|
||||
|
||||
override fun onUnregistrationFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
trySendBlocking(null)
|
||||
logger.log("un-registration failed, error code: $errorCode")
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
nsdManager.registerService(serviceInfo, NsdManager.PROTOCOL_DNS_SD, listener)
|
||||
awaitClose {
|
||||
nsdManager.unregisterService(listener)
|
||||
trySendBlocking(null)
|
||||
}
|
||||
}
|
||||
}
|
@ -1,29 +0,0 @@
|
||||
package com.m3u.data.tv.nsd
|
||||
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
|
||||
internal class NsdResolveListener(
|
||||
private val onResolved: (NsdServiceInfo?) -> Unit,
|
||||
private val onResolveFailed: (NsdServiceInfo?) -> Unit,
|
||||
private val onResolvedStopped: (NsdServiceInfo) -> Unit = {},
|
||||
private val onStopResolutionFailed: (NsdServiceInfo) -> Unit = {}
|
||||
) : NsdManager.ResolveListener {
|
||||
override fun onServiceResolved(serviceInfo: NsdServiceInfo?) {
|
||||
onResolved(serviceInfo)
|
||||
}
|
||||
|
||||
override fun onResolveFailed(serviceInfo: NsdServiceInfo?, errorCode: Int) {
|
||||
onResolveFailed(serviceInfo)
|
||||
}
|
||||
|
||||
override fun onResolutionStopped(serviceInfo: NsdServiceInfo) {
|
||||
super.onResolutionStopped(serviceInfo)
|
||||
onResolvedStopped(serviceInfo)
|
||||
}
|
||||
|
||||
override fun onStopResolutionFailed(serviceInfo: NsdServiceInfo, errorCode: Int) {
|
||||
super.onStopResolutionFailed(serviceInfo, errorCode)
|
||||
onStopResolutionFailed(serviceInfo)
|
||||
}
|
||||
}
|
Submodule extension/repos deleted from 29ed94eb32
Reference in New Issue
Block a user