fix: code clean up.

This commit is contained in:
oxy-macmini
2025-05-06 22:08:56 +08:00
parent 010b5409fd
commit b909f07e4d
45 changed files with 28 additions and 2322 deletions

2
.idea/vcs.xml generated
View File

@ -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>

View File

@ -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
}

View File

@ -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
)
}
}
}
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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()
) {

View File

@ -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))
)
}
}
}

View File

@ -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")
}
}
}

View File

@ -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)
)
}
}
}

View File

@ -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 -> {}
}
}
}
}
}

View File

@ -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

View File

@ -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)
)
}
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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()
)
}
}
}
)

View File

@ -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"
)
}
}

View File

@ -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("")

View File

@ -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

View File

@ -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
}

View File

@ -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()
}

View File

@ -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>
}

View File

@ -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
}
}

View File

@ -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()
}
}
}

View File

@ -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
}

View File

@ -1,7 +0,0 @@
package com.m3u.data.repository.other
import com.m3u.data.api.dto.github.Release
interface OtherRepository {
suspend fun release(): Release?
}

View File

@ -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()
}
}

View File

@ -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
}

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}
}

View File

@ -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
}
}

View File

@ -1,6 +0,0 @@
package com.m3u.data.tv.http
interface HttpServer {
fun start(port: Int)
fun stop()
}

View File

@ -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
}
}
}

View File

@ -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
)

View File

@ -1,7 +0,0 @@
package com.m3u.data.tv.http.endpoint
import io.ktor.server.routing.Route
sealed interface Endpoint {
fun apply(route: Route)
}

View File

@ -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)
)
}
}
}
}

View File

@ -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)
)
}
}
}
}
}

View File

@ -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 -> {}
}
}
}
}
}
}

View File

@ -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
}

View File

@ -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
)

View File

@ -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"
}
}

View File

@ -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)
}
}
}

View File

@ -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