mirror of
https://github.com/oxyroid/M3UAndroid.git
synced 2025-05-17 03:16:01 +08:00
feat: listen connection to television.
This commit is contained in:
7
RULES.md
7
RULES.md
@ -15,4 +15,9 @@
|
||||
9. Never use view-based XML, you can use view in AndroidView composable only.
|
||||
10. Never use Painter to inflate drawable resources, use `ImageVector.vectorResource` instead.
|
||||
11. If you wanna to add some libraries, please make sure they are located in MavenCentral, google or
|
||||
jitpack repository. And jar library is not allowed as well.
|
||||
jitpack repository. And jar library is not allowed as well.
|
||||
12. Due to compatibility needs, for data table `playlists` and `streams`,
|
||||
please do not change the existing column names(referring to the real field names mapped to the
|
||||
database, that is, the name field defined in ColumnInfo),
|
||||
and remember the new fields must have default value (it needs to be defined in the
|
||||
defaultValue field in both the data class and ColumnInfo).
|
||||
|
@ -2,6 +2,7 @@ package com.m3u.androidApp
|
||||
|
||||
import android.app.Application
|
||||
import android.os.Build
|
||||
import com.m3u.core.architecture.Abi
|
||||
import com.m3u.core.architecture.Publisher
|
||||
import com.m3u.material.ktx.isTelevision
|
||||
import javax.inject.Inject
|
||||
@ -13,6 +14,7 @@ class AppPublisher @Inject constructor(private val application: Application) : P
|
||||
override val debug: Boolean = BuildConfig.DEBUG
|
||||
override val snapshot: Boolean = "snapshot" in BuildConfig.FLAVOR
|
||||
override val model: String = Build.MODEL
|
||||
override val abi: Abi = Abi.of(Build.SUPPORTED_ABIS[0])
|
||||
override val isTelevision: Boolean
|
||||
get() = application.resources.configuration.isTelevision()
|
||||
}
|
@ -20,7 +20,7 @@ class M3UApplication : Application(), Configuration.Provider {
|
||||
lateinit var handler: CrashHandler
|
||||
|
||||
@Inject
|
||||
@Logger.Message
|
||||
@Logger.MessageImpl
|
||||
lateinit var messager: Logger
|
||||
|
||||
@Inject
|
||||
|
@ -45,8 +45,8 @@ import com.m3u.core.util.context.isPortraitMode
|
||||
import com.m3u.core.util.coroutine.getValue
|
||||
import com.m3u.core.util.coroutine.setValue
|
||||
import com.m3u.core.wrapper.Message
|
||||
import com.m3u.data.local.service.MessageManager
|
||||
import com.m3u.data.local.service.PlayerManager
|
||||
import com.m3u.data.service.MessageManager
|
||||
import com.m3u.data.service.PlayerManager
|
||||
import com.m3u.ui.Toolkit
|
||||
import com.m3u.ui.helper.Action
|
||||
import com.m3u.ui.helper.Fob
|
||||
@ -84,7 +84,7 @@ class MainActivity : AppCompatActivity() {
|
||||
lateinit var pref: Pref
|
||||
|
||||
@Inject
|
||||
@Logger.Message
|
||||
@Logger.MessageImpl
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
|
@ -5,7 +5,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.m3u.core.architecture.pref.Pref
|
||||
import com.m3u.data.local.service.MessageManager
|
||||
import com.m3u.data.service.MessageManager
|
||||
import com.m3u.ui.Destination
|
||||
import com.m3u.ui.helper.Action
|
||||
import com.m3u.ui.helper.Fob
|
||||
|
@ -24,7 +24,8 @@ subprojects {
|
||||
"-opt-in=androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi",
|
||||
"-opt-in=androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi",
|
||||
"-opt-in=androidx.compose.material3.adaptive.navigation.suite.ExperimentalMaterial3AdaptiveNavigationSuiteApi",
|
||||
"-opt-in=androidx.tv.material3.ExperimentalTvMaterial3Api"
|
||||
"-opt-in=androidx.tv.material3.ExperimentalTvMaterial3Api",
|
||||
"-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
|
||||
)
|
||||
}
|
||||
val path = project.layout.buildDirectory.dir("compose_metrics").get().asFile.path
|
||||
|
@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.com.android.library)
|
||||
alias(libs.plugins.org.jetbrains.kotlin.android)
|
||||
alias(libs.plugins.org.jetbrains.kotlin.serialization)
|
||||
alias(libs.plugins.com.google.devtools.ksp)
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
@ -44,6 +45,8 @@ dependencies {
|
||||
implementation(libs.androidx.media3.media3.session)
|
||||
implementation(libs.com.google.dagger.hilt.android)
|
||||
ksp(libs.com.google.dagger.hilt.compiler)
|
||||
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
api(libs.kotlinx.datetime)
|
||||
}
|
@ -1,5 +1,7 @@
|
||||
package com.m3u.core.architecture
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
interface Publisher {
|
||||
val author: String get() = "realOxy"
|
||||
val repository: String get() = "M3UAndroid"
|
||||
@ -9,5 +11,39 @@ interface Publisher {
|
||||
val debug: Boolean
|
||||
val snapshot: Boolean
|
||||
val model: String
|
||||
val abi: Abi
|
||||
val isTelevision: Boolean
|
||||
}
|
||||
|
||||
@JvmInline
|
||||
@Serializable
|
||||
value class Abi private constructor(
|
||||
val value: String
|
||||
) {
|
||||
fun accept(self: Abi, target: Abi): Boolean {
|
||||
check(self != unsupported) { "self abi cannot be unsupported!" }
|
||||
if (target == unsupported) return false
|
||||
if (self == target) return true
|
||||
return self == universal
|
||||
}
|
||||
|
||||
companion object {
|
||||
val universal = Abi("universal")
|
||||
val x86 = Abi("x86")
|
||||
val x86_64 = Abi("x86_64")
|
||||
val arm64_v8a = Abi("arm64-v8a")
|
||||
val arm64_v7a = Abi("arm64-v7a")
|
||||
val unsupported = Abi("unsupported")
|
||||
|
||||
fun of(value: String): Abi {
|
||||
return when (value) {
|
||||
x86.value -> x86
|
||||
x86_64.value -> x86_64
|
||||
arm64_v7a.value -> arm64_v7a
|
||||
arm64_v8a.value -> arm64_v8a
|
||||
universal.value -> universal
|
||||
else -> unsupported
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
package com.m3u.core.architecture.logger
|
||||
|
||||
import com.m3u.core.wrapper.Message.Companion.LEVEL_ERROR
|
||||
import com.m3u.core.wrapper.Message
|
||||
import javax.inject.Qualifier
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@ -8,10 +8,10 @@ import kotlin.time.Duration.Companion.seconds
|
||||
interface Logger {
|
||||
fun log(
|
||||
text: String,
|
||||
level: Int = LEVEL_ERROR,
|
||||
level: Int = Message.LEVEL_ERROR,
|
||||
tag: String = "LOGGER",
|
||||
duration: Duration = 3.seconds,
|
||||
type: Int = com.m3u.core.wrapper.Message.TYPE_SNACK
|
||||
duration: Duration = 5.seconds,
|
||||
type: Int = Message.TYPE_SNACK
|
||||
)
|
||||
|
||||
fun log(
|
||||
@ -21,7 +21,7 @@ interface Logger {
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.BINARY)
|
||||
annotation class Message
|
||||
annotation class MessageImpl
|
||||
}
|
||||
|
||||
fun Logger.prefix(text: String): PrefixLogger = PrefixLogger(this, text)
|
||||
|
@ -79,7 +79,7 @@ dependencies {
|
||||
|
||||
implementation(libs.nextlib.media3ext)
|
||||
|
||||
implementation(libs.ktor.server.jetty)
|
||||
implementation(libs.ktor.server.netty)
|
||||
implementation(libs.ktor.server.websockets)
|
||||
implementation(libs.ktor.server.cors)
|
||||
implementation(libs.ktor.server.content.negotiation)
|
||||
|
164
data/schemas/com.m3u.data.database.M3UDatabase/9.json
Normal file
164
data/schemas/com.m3u.data.database.M3UDatabase/9.json
Normal file
@ -0,0 +1,164 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"database": {
|
||||
"version": 9,
|
||||
"identityHash": "525922b9d8fae525560b9f1e503fffe6",
|
||||
"entities": [
|
||||
{
|
||||
"tableName": "streams",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `group` TEXT NOT NULL, `title` TEXT NOT NULL, `cover` TEXT, `playlistUrl` TEXT NOT NULL, `license_type` TEXT DEFAULT NULL, `license_key` TEXT DEFAULT NULL, `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `favourite` INTEGER NOT NULL, `hidden` INTEGER NOT NULL DEFAULT 0, `seen` INTEGER NOT NULL DEFAULT 0)",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "group",
|
||||
"columnName": "group",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "cover",
|
||||
"columnName": "cover",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false
|
||||
},
|
||||
{
|
||||
"fieldPath": "playlistUrl",
|
||||
"columnName": "playlistUrl",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "licenseType",
|
||||
"columnName": "license_type",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false,
|
||||
"defaultValue": "NULL"
|
||||
},
|
||||
{
|
||||
"fieldPath": "licenseKey",
|
||||
"columnName": "license_key",
|
||||
"affinity": "TEXT",
|
||||
"notNull": false,
|
||||
"defaultValue": "NULL"
|
||||
},
|
||||
{
|
||||
"fieldPath": "id",
|
||||
"columnName": "id",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "favourite",
|
||||
"columnName": "favourite",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "hidden",
|
||||
"columnName": "hidden",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
},
|
||||
{
|
||||
"fieldPath": "seen",
|
||||
"columnName": "seen",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true,
|
||||
"defaultValue": "0"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "playlists",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`title` TEXT NOT NULL, `url` TEXT NOT NULL, `pinned_groups` TEXT NOT NULL DEFAULT '[]', PRIMARY KEY(`url`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "title",
|
||||
"columnName": "title",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "url",
|
||||
"columnName": "url",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "pinnedGroups",
|
||||
"columnName": "pinned_groups",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true,
|
||||
"defaultValue": "'[]'"
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"url"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
},
|
||||
{
|
||||
"tableName": "color_pack",
|
||||
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`argb` INTEGER NOT NULL, `dark` INTEGER NOT NULL, `name` TEXT NOT NULL, PRIMARY KEY(`argb`, `dark`))",
|
||||
"fields": [
|
||||
{
|
||||
"fieldPath": "argb",
|
||||
"columnName": "argb",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "isDark",
|
||||
"columnName": "dark",
|
||||
"affinity": "INTEGER",
|
||||
"notNull": true
|
||||
},
|
||||
{
|
||||
"fieldPath": "name",
|
||||
"columnName": "name",
|
||||
"affinity": "TEXT",
|
||||
"notNull": true
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"argb",
|
||||
"dark"
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '525922b9d8fae525560b9f1e503fffe6')"
|
||||
]
|
||||
}
|
||||
}
|
@ -1,10 +1,21 @@
|
||||
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.local.http.endpoint.Playlists
|
||||
import com.m3u.data.local.http.endpoint.SayHello
|
||||
import com.m3u.data.television.http.endpoint.Playlists
|
||||
import com.m3u.data.television.http.endpoint.SayHello
|
||||
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
|
||||
@ -27,12 +38,15 @@ interface LocalService {
|
||||
@Singleton
|
||||
class LocalPreparedService @Inject constructor(
|
||||
private val builder: Retrofit.Builder,
|
||||
@Logger.Message private val logger: Logger
|
||||
private val okHttpClient: OkHttpClient,
|
||||
@Logger.MessageImpl private val logger: Logger,
|
||||
private val publisher: Publisher,
|
||||
) : LocalService {
|
||||
override suspend fun sayHello(): SayHello.Rep? = logger.execute {
|
||||
val api = checkNotNull(api) { "You haven't connected television" }
|
||||
api.sayHello()
|
||||
}
|
||||
|
||||
override suspend fun subscribe(
|
||||
title: String,
|
||||
url: String
|
||||
@ -43,16 +57,45 @@ class LocalPreparedService @Inject constructor(
|
||||
|
||||
private var api: LocalService? = null
|
||||
|
||||
fun prepare(host: String, port: Int) {
|
||||
fun prepare(host: String, port: Int): Flow<SayHello.Rep> = 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) {
|
||||
val rep = json.decodeFromString<SayHello.Rep>(text)
|
||||
trySendBlocking(rep)
|
||||
}
|
||||
|
||||
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
|
||||
webSocket.cancel()
|
||||
cancel(reason)
|
||||
}
|
||||
}
|
||||
val webSocket = okHttpClient.newWebSocket(request, listener)
|
||||
awaitClose {
|
||||
webSocket.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
|
@ -32,4 +32,10 @@ internal object DatabaseMigrations {
|
||||
db.execSQL("ALTER TABLE playlists ADD COLUMN pinned_groups TEXT NOT NULL DEFAULT '[]'")
|
||||
}
|
||||
}
|
||||
@RenameColumn(
|
||||
tableName = "streams",
|
||||
fromColumnName = "banned",
|
||||
toColumnName = "hidden"
|
||||
)
|
||||
class AutoMigration8To9: AutoMigrationSpec
|
||||
}
|
@ -13,7 +13,7 @@ import com.m3u.data.database.model.Stream
|
||||
|
||||
@Database(
|
||||
entities = [Stream::class, Playlist::class, ColorPack::class],
|
||||
version = 8,
|
||||
version = 9,
|
||||
exportSchema = true,
|
||||
autoMigrations = [
|
||||
AutoMigration(
|
||||
@ -23,7 +23,12 @@ import com.m3u.data.database.model.Stream
|
||||
),
|
||||
AutoMigration(from = 4, to = 5),
|
||||
AutoMigration(from = 5, to = 6),
|
||||
AutoMigration(from = 6, to = 7)
|
||||
AutoMigration(from = 6, to = 7),
|
||||
AutoMigration(
|
||||
from = 8,
|
||||
to = 9,
|
||||
spec = DatabaseMigrations.AutoMigration8To9::class
|
||||
)
|
||||
]
|
||||
)
|
||||
@TypeConverters(Converters::class)
|
||||
|
@ -52,8 +52,8 @@ interface StreamDao {
|
||||
@Query("UPDATE streams SET favourite = :target WHERE id = :id")
|
||||
suspend fun setFavourite(id: Int, target: Boolean)
|
||||
|
||||
@Query("UPDATE streams SET banned = :target WHERE id = :id")
|
||||
suspend fun ban(id: Int, target: Boolean)
|
||||
@Query("UPDATE streams SET hidden = :target WHERE id = :id")
|
||||
suspend fun hide(id: Int, target: Boolean)
|
||||
|
||||
@Query("UPDATE streams SET seen = :target WHERE id = :id")
|
||||
suspend fun updateSeen(id: Int, target: Long)
|
||||
|
@ -33,8 +33,8 @@ data class Stream(
|
||||
// extra fields
|
||||
@ColumnInfo(name = "favourite")
|
||||
val favourite: Boolean = false,
|
||||
@ColumnInfo(name = "banned")
|
||||
val banned: Boolean = false,
|
||||
@ColumnInfo(name = "hidden", defaultValue = "0")
|
||||
val hidden: Boolean = false,
|
||||
@ColumnInfo(name = "seen", defaultValue = "0")
|
||||
val seen: Long = 0L,
|
||||
) : Likable<Stream> {
|
||||
|
@ -1,43 +0,0 @@
|
||||
package com.m3u.data.local.http.endpoint
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import com.m3u.core.architecture.Publisher
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
import io.ktor.server.routing.route
|
||||
import io.ktor.server.websocket.webSocket
|
||||
import kotlinx.serialization.Serializable
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
data class SayHello @Inject constructor(
|
||||
private val publisher: Publisher
|
||||
) : Endpoint {
|
||||
override fun apply(route: Route) {
|
||||
route.route("/say_hello") {
|
||||
get {
|
||||
call.respond(publisher.model)
|
||||
}
|
||||
webSocket {
|
||||
for (frame in incoming) {
|
||||
val rep = Rep(
|
||||
model = publisher.model,
|
||||
version = publisher.versionCode,
|
||||
snapshot = publisher.snapshot
|
||||
)
|
||||
call.respond(rep)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
@Serializable
|
||||
data class Rep(
|
||||
val model: String,
|
||||
val version: Int,
|
||||
val snapshot: Boolean
|
||||
)
|
||||
}
|
@ -37,12 +37,8 @@ fun PlaylistRepository.refresh(
|
||||
@PlaylistStrategy strategy: Int
|
||||
): Flow<Resource<Unit>> = channelFlow {
|
||||
try {
|
||||
val playlist = get(url) ?: error("Cannot find playlist: $url")
|
||||
if (playlist.fromLocal) {
|
||||
// refreshing is not needed for local storage playlist.
|
||||
send(Resource.Success(Unit))
|
||||
return@channelFlow
|
||||
}
|
||||
val playlist = checkNotNull(get(url)) { "Cannot find playlist: $url" }
|
||||
check(!playlist.fromLocal) { "refreshing is not needed for local storage playlist." }
|
||||
subscribe(playlist.title, url, strategy)
|
||||
.onEach(::send)
|
||||
.launchIn(this)
|
||||
|
@ -13,7 +13,7 @@ interface StreamRepository {
|
||||
suspend fun getByUrl(url: String): Stream?
|
||||
suspend fun getByPlaylistUrl(playlistUrl: String): List<Stream>
|
||||
suspend fun setFavourite(id: Int, target: Boolean)
|
||||
suspend fun ban(id: Int, target: Boolean)
|
||||
suspend fun hide(id: Int, target: Boolean)
|
||||
suspend fun reportPlayed(id: Int)
|
||||
suspend fun getPlayedRecently(): Stream?
|
||||
fun observeAllUnseenFavourites(limit: Duration): Flow<List<Stream>>
|
||||
|
@ -0,0 +1,26 @@
|
||||
package com.m3u.data.repository
|
||||
|
||||
import com.m3u.data.television.http.endpoint.SayHello
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
abstract class TelevisionRepository {
|
||||
abstract val broadcastCodeOnTelevision: StateFlow<Int?>
|
||||
|
||||
protected abstract fun broadcastOnTelevision()
|
||||
protected abstract fun closeBroadcastOnTelevision()
|
||||
|
||||
abstract val connectedTelevision: StateFlow<SayHello.Rep?>
|
||||
|
||||
abstract fun connectToTelevision(code: Int, timeout: Duration = 8.seconds): Flow<ConnectionToTelevision>
|
||||
abstract suspend fun disconnectToTelevision()
|
||||
}
|
||||
|
||||
sealed interface ConnectionToTelevision {
|
||||
data class Idle(val reason: String? = null) : ConnectionToTelevision
|
||||
data object Timeout : ConnectionToTelevision
|
||||
data object Searching: ConnectionToTelevision
|
||||
data class Completed(val host: String, val port: Int) : ConnectionToTelevision
|
||||
}
|
@ -1,21 +0,0 @@
|
||||
package com.m3u.data.repository
|
||||
|
||||
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 pinCodeForServer: StateFlow<Int?>
|
||||
protected abstract fun startForServer()
|
||||
protected abstract fun stopForServer()
|
||||
|
||||
abstract fun pairForClient(pin: Int, timeout: Duration = 8.seconds): Flow<PairState>
|
||||
}
|
||||
|
||||
sealed interface PairState {
|
||||
data object Idle : PairState
|
||||
data object Timeout : PairState
|
||||
data object Connecting : PairState
|
||||
data class Connected(val host: String, val port: Int) : PairState
|
||||
}
|
@ -5,11 +5,11 @@ package com.m3u.data.repository.di
|
||||
import com.m3u.data.repository.MediaRepository
|
||||
import com.m3u.data.repository.PlaylistRepository
|
||||
import com.m3u.data.repository.StreamRepository
|
||||
import com.m3u.data.repository.TvRepository
|
||||
import com.m3u.data.repository.TelevisionRepository
|
||||
import com.m3u.data.repository.internal.MediaRepositoryImpl
|
||||
import com.m3u.data.repository.internal.PlaylistRepositoryImpl
|
||||
import com.m3u.data.repository.internal.StreamRepositoryImpl
|
||||
import com.m3u.data.repository.internal.TvRepositoryImpl
|
||||
import com.m3u.data.repository.internal.TelevisionRepositoryImpl
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
@ -39,7 +39,7 @@ interface RepositoryModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
fun bindTvRepository(
|
||||
repository: TvRepositoryImpl
|
||||
): TvRepository
|
||||
fun bindTelevisionRepository(
|
||||
repository: TelevisionRepositoryImpl
|
||||
): TelevisionRepository
|
||||
}
|
||||
|
@ -1,12 +1,13 @@
|
||||
package com.m3u.data.work
|
||||
package com.m3u.data.repository.internal
|
||||
|
||||
object BackupContracts {
|
||||
object BackupOrRestoreContracts {
|
||||
fun wrapPlaylist(encoded: String): String = "P,$encoded"
|
||||
fun wrapStream(encoded: String): String = "S,$encoded"
|
||||
fun unwrapPlaylist(wrapped: String): String? {
|
||||
if (!wrapped.startsWith("P,")) return null
|
||||
return wrapped.drop(2)
|
||||
}
|
||||
|
||||
fun unwrapStream(wrapped: String): String? {
|
||||
if (!wrapped.startsWith("S,")) return null
|
||||
return wrapped.drop(2)
|
@ -27,7 +27,6 @@ import com.m3u.data.database.model.Stream
|
||||
import com.m3u.data.repository.PlaylistRepository
|
||||
import com.m3u.data.repository.parser.M3UPlaylistParser
|
||||
import com.m3u.data.repository.parser.model.toStream
|
||||
import com.m3u.data.work.BackupContracts
|
||||
import com.m3u.i18n.R.string
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@ -48,7 +47,7 @@ import javax.inject.Inject
|
||||
class PlaylistRepositoryImpl @Inject constructor(
|
||||
private val playlistDao: PlaylistDao,
|
||||
private val streamDao: StreamDao,
|
||||
@Logger.Message private val logger: Logger,
|
||||
@Logger.MessageImpl private val logger: Logger,
|
||||
private val client: OkHttpClient,
|
||||
@M3UPlaylistParser.Default private val parser: M3UPlaylistParser,
|
||||
@ApplicationContext private val context: Context,
|
||||
@ -105,11 +104,11 @@ class PlaylistRepositoryImpl @Inject constructor(
|
||||
all.forEach { (playlist, streams) ->
|
||||
if (playlist.fromLocal) return@forEach
|
||||
val encodedPlaylist = json.encodeToString(playlist)
|
||||
val wrappedPlaylist = BackupContracts.wrapPlaylist(encodedPlaylist)
|
||||
val wrappedPlaylist = BackupOrRestoreContracts.wrapPlaylist(encodedPlaylist)
|
||||
writer.appendLine(wrappedPlaylist)
|
||||
streams.forEach { stream ->
|
||||
val encodedStream = json.encodeToString(stream)
|
||||
val wrappedStream = BackupContracts.wrapStream(encodedStream)
|
||||
val wrappedStream = BackupOrRestoreContracts.wrapStream(encodedStream)
|
||||
writer.appendLine(wrappedStream)
|
||||
}
|
||||
}
|
||||
@ -125,8 +124,8 @@ class PlaylistRepositoryImpl @Inject constructor(
|
||||
|
||||
reader.forEachLine { line ->
|
||||
if (line.isBlank()) return@forEachLine
|
||||
val encodedPlaylist = BackupContracts.unwrapPlaylist(line)
|
||||
val encodedStream = BackupContracts.unwrapStream(line)
|
||||
val encodedPlaylist = BackupOrRestoreContracts.unwrapPlaylist(line)
|
||||
val encodedStream = BackupOrRestoreContracts.unwrapStream(line)
|
||||
when {
|
||||
encodedPlaylist != null -> {
|
||||
val playlist = json.decodeFromString<Playlist>(encodedPlaylist)
|
||||
|
@ -40,8 +40,8 @@ class StreamRepositoryImpl @Inject constructor(
|
||||
streamDao.setFavourite(id, target)
|
||||
}
|
||||
|
||||
override suspend fun ban(id: Int, target: Boolean) = logger.sandBox {
|
||||
streamDao.ban(id, target)
|
||||
override suspend fun hide(id: Int, target: Boolean) = logger.sandBox {
|
||||
streamDao.hide(id, target)
|
||||
}
|
||||
|
||||
override suspend fun reportPlayed(id: Int) = logger.sandBox {
|
||||
|
@ -9,24 +9,26 @@ import com.m3u.core.architecture.logger.prefix
|
||||
import com.m3u.core.architecture.pref.Pref
|
||||
import com.m3u.core.architecture.pref.observeAsFlow
|
||||
import com.m3u.core.util.coroutine.onTimeout
|
||||
import com.m3u.data.local.http.internal.Utils
|
||||
import com.m3u.data.api.LocalPreparedService
|
||||
import com.m3u.data.local.http.HttpServer
|
||||
import com.m3u.data.local.nsd.NsdDeviceManager
|
||||
import com.m3u.data.repository.PairState
|
||||
import com.m3u.data.repository.TvRepository
|
||||
import com.m3u.data.television.http.HttpServer
|
||||
import com.m3u.data.television.http.endpoint.SayHello
|
||||
import com.m3u.data.television.http.internal.Utils
|
||||
import com.m3u.data.television.nsd.NsdDeviceManager
|
||||
import com.m3u.data.repository.ConnectionToTelevision
|
||||
import com.m3u.data.repository.TelevisionRepository
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
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.combine
|
||||
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
|
||||
@ -35,7 +37,7 @@ import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
||||
class TvRepositoryImpl @Inject constructor(
|
||||
class TelevisionRepositoryImpl @Inject constructor(
|
||||
private val nsdDeviceManager: NsdDeviceManager,
|
||||
private val server: HttpServer,
|
||||
private val localService: LocalPreparedService,
|
||||
@ -43,30 +45,33 @@ class TvRepositoryImpl @Inject constructor(
|
||||
pref: Pref,
|
||||
publisher: Publisher,
|
||||
@Dispatcher(IO) ioDispatcher: CoroutineDispatcher
|
||||
) : TvRepository() {
|
||||
) : TelevisionRepository() {
|
||||
private val logger = logger.prefix("tv-repos")
|
||||
private val isTelevision = publisher.isTelevision
|
||||
private val coroutineScope = CoroutineScope(ioDispatcher)
|
||||
|
||||
init {
|
||||
pref
|
||||
.observeAsFlow { it.remoteControl }
|
||||
.onEach { remoteControl ->
|
||||
when {
|
||||
remoteControl && (isTelevision || pref.alwaysTv) -> startForServer()
|
||||
else -> stopForServer()
|
||||
}
|
||||
combine(
|
||||
pref.observeAsFlow { it.remoteControl },
|
||||
pref.observeAsFlow { it.alwaysTv }
|
||||
) { remoteControl, alwaysTv ->
|
||||
when {
|
||||
!remoteControl -> closeBroadcastOnTelevision()
|
||||
alwaysTv || isTelevision -> broadcastOnTelevision()
|
||||
else -> closeBroadcastOnTelevision()
|
||||
}
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
|
||||
private val _pinCodeForServer = MutableStateFlow<Int?>(null)
|
||||
override val pinCodeForServer: StateFlow<Int?> = _pinCodeForServer.asStateFlow()
|
||||
override val broadcastCodeOnTelevision = _pinCodeForServer.asStateFlow()
|
||||
|
||||
private var serverJob: Job? = null
|
||||
override fun startForServer() {
|
||||
|
||||
override fun broadcastOnTelevision() {
|
||||
val serverPort = Utils.findPort()
|
||||
stopForServer()
|
||||
closeBroadcastOnTelevision()
|
||||
server.start(serverPort)
|
||||
serverJob = coroutineScope.launch {
|
||||
while (isActive) {
|
||||
@ -101,41 +106,60 @@ class TvRepositoryImpl @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
override fun stopForServer() {
|
||||
override fun closeBroadcastOnTelevision() {
|
||||
server.stop()
|
||||
serverJob?.cancel()
|
||||
serverJob = null
|
||||
}
|
||||
|
||||
override fun pairForClient(pin: Int, timeout: Duration): Flow<PairState> = channelFlow {
|
||||
trySendBlocking(PairState.Idle)
|
||||
nsdDeviceManager
|
||||
private val _connectedToTelevision = MutableStateFlow<SayHello.Rep?>(null)
|
||||
override val connectedTelevision = _connectedToTelevision.asStateFlow()
|
||||
|
||||
private var connectionToTelevisionJob: Job? = null
|
||||
override fun connectToTelevision(
|
||||
code: Int,
|
||||
timeout: Duration
|
||||
): Flow<ConnectionToTelevision> = channelFlow {
|
||||
val completed = nsdDeviceManager
|
||||
.search()
|
||||
.onStart {
|
||||
logger.log("pair: start")
|
||||
trySendBlocking(PairState.Connecting)
|
||||
}
|
||||
.onStart { trySendBlocking(ConnectionToTelevision.Searching) }
|
||||
.onTimeout(timeout) {
|
||||
logger.log("pair: timeout")
|
||||
trySendBlocking(PairState.Timeout)
|
||||
trySendBlocking(ConnectionToTelevision.Timeout)
|
||||
}
|
||||
.onEach { all ->
|
||||
.mapNotNull { all ->
|
||||
logger.log("pair: all devices: $all")
|
||||
val info = all.find {
|
||||
it.getAttribute(NsdDeviceManager.META_DATA_PIN) == pin.toString()
|
||||
} ?: return@onEach
|
||||
it.getAttribute(NsdDeviceManager.META_DATA_PIN) == code.toString()
|
||||
} ?: return@mapNotNull null
|
||||
val port = info.getAttribute(NsdDeviceManager.META_DATA_PORT)
|
||||
?.toIntOrNull()
|
||||
?: return@onEach
|
||||
?: return@mapNotNull null
|
||||
val host =
|
||||
info.getAttribute(NsdDeviceManager.META_DATA_HOST) ?: return@onEach
|
||||
info.getAttribute(NsdDeviceManager.META_DATA_HOST) ?: return@mapNotNull null
|
||||
|
||||
logger.log("pair: connected")
|
||||
trySendBlocking(PairState.Connected(host, port))
|
||||
localService.prepare(host, port)
|
||||
cancel()
|
||||
logger.log("pair: connecting")
|
||||
ConnectionToTelevision.Completed(host, port)
|
||||
}
|
||||
.launchIn(this)
|
||||
.firstOrNull()
|
||||
|
||||
if (completed != null) {
|
||||
trySendBlocking(completed)
|
||||
connectionToTelevisionJob?.cancel()
|
||||
connectionToTelevisionJob = localService
|
||||
.prepare(completed.host, completed.port)
|
||||
.onEach { rep ->
|
||||
logger.log("pair: connected")
|
||||
_connectedToTelevision.value = rep
|
||||
}
|
||||
.launchIn(coroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun disconnectToTelevision() {
|
||||
connectionToTelevisionJob?.cancel()
|
||||
connectionToTelevisionJob = null
|
||||
_connectedToTelevision.value = null
|
||||
}
|
||||
|
||||
private fun NsdServiceInfo.getAttribute(key: String): String? =
|
@ -2,7 +2,7 @@ package com.m3u.data.repository.logger
|
||||
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.core.wrapper.Message
|
||||
import com.m3u.data.local.service.MessageManager
|
||||
import com.m3u.data.service.MessageManager
|
||||
import java.util.Locale
|
||||
import javax.inject.Inject
|
||||
import kotlin.time.Duration
|
||||
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.local.service
|
||||
package com.m3u.data.service
|
||||
|
||||
import com.m3u.core.wrapper.Message
|
||||
import kotlinx.coroutines.flow.StateFlow
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.local.service
|
||||
package com.m3u.data.service
|
||||
|
||||
import android.graphics.Rect
|
||||
import androidx.media3.common.C
|
@ -1,23 +1,21 @@
|
||||
@file:Suppress("unused")
|
||||
|
||||
package com.m3u.data.local.service
|
||||
package com.m3u.data.service
|
||||
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.wifi.WifiManager
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.work.WorkManager
|
||||
import com.m3u.core.architecture.TraceFileProvider
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.data.local.http.HttpServer
|
||||
import com.m3u.data.local.http.internal.HttpServerImpl
|
||||
import com.m3u.data.local.nsd.NsdDeviceManager
|
||||
import com.m3u.data.local.nsd.internal.NsdDeviceManagerImpl
|
||||
import com.m3u.data.local.service.internal.MessageManagerImpl
|
||||
import com.m3u.data.local.service.internal.PlayerManagerImpl
|
||||
import com.m3u.data.local.service.internal.TraceFileProviderImpl
|
||||
import com.m3u.data.television.http.HttpServer
|
||||
import com.m3u.data.television.http.internal.HttpServerImpl
|
||||
import com.m3u.data.television.nsd.NsdDeviceManager
|
||||
import com.m3u.data.television.nsd.internal.NsdDeviceManagerImpl
|
||||
import com.m3u.data.service.internal.MessageManagerImpl
|
||||
import com.m3u.data.service.internal.PlayerManagerImpl
|
||||
import com.m3u.data.service.internal.TraceFileProviderImpl
|
||||
import com.m3u.data.repository.logger.CommonLogger
|
||||
import com.m3u.data.repository.logger.MessageLogger
|
||||
import dagger.Binds
|
||||
@ -49,7 +47,7 @@ internal interface BindServicesModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
@Logger.Message
|
||||
@Logger.MessageImpl
|
||||
fun bindMessageLogger(logger: MessageLogger): Logger
|
||||
|
||||
@Binds
|
||||
@ -82,18 +80,6 @@ object ProvidedServicesModule {
|
||||
return context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideWifiManager(@ApplicationContext context: Context): WifiManager {
|
||||
return context.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideConnectivityManager(@ApplicationContext context: Context): ConnectivityManager {
|
||||
return context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideNsdManager(@ApplicationContext context: Context): NsdManager {
|
@ -1,9 +1,9 @@
|
||||
package com.m3u.data.local.service.internal
|
||||
package com.m3u.data.service.internal
|
||||
|
||||
import com.m3u.core.architecture.dispatcher.Dispatcher
|
||||
import com.m3u.core.architecture.dispatcher.M3uDispatchers.IO
|
||||
import com.m3u.core.wrapper.Message
|
||||
import com.m3u.data.local.service.MessageManager
|
||||
import com.m3u.data.service.MessageManager
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.local.service.internal
|
||||
package com.m3u.data.service.internal
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Rect
|
||||
@ -33,7 +33,7 @@ import com.m3u.core.architecture.dispatcher.M3uDispatchers.Main
|
||||
import com.m3u.core.architecture.pref.Pref
|
||||
import com.m3u.core.architecture.pref.annotation.ReconnectMode
|
||||
import com.m3u.core.architecture.pref.observeAsFlow
|
||||
import com.m3u.data.local.service.PlayerManager
|
||||
import com.m3u.data.service.PlayerManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.github.anilbeesetti.nextlib.media3ext.ffdecoder.NextRenderersFactory
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.local.service.internal
|
||||
package com.m3u.data.service.internal
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageInfo
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.local.http
|
||||
package com.m3u.data.television.http
|
||||
|
||||
interface HttpServer {
|
||||
fun start(port: Int)
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.local.http.endpoint
|
||||
package com.m3u.data.television.http.endpoint
|
||||
|
||||
import io.ktor.server.routing.Route
|
||||
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.local.http.endpoint
|
||||
package com.m3u.data.television.http.endpoint
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.Keep
|
||||
@ -7,7 +7,7 @@ import androidx.work.OutOfQuotaPolicy
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.workDataOf
|
||||
import com.m3u.core.architecture.pref.Pref
|
||||
import com.m3u.data.work.SubscriptionWorker
|
||||
import com.m3u.data.worker.SubscriptionWorker
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
@ -0,0 +1,75 @@
|
||||
package com.m3u.data.television.http.endpoint
|
||||
|
||||
import androidx.annotation.Keep
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.m3u.core.architecture.Abi
|
||||
import com.m3u.core.architecture.Publisher
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.core.wrapper.Message
|
||||
import io.ktor.server.response.respond
|
||||
import io.ktor.server.routing.Route
|
||||
import io.ktor.server.routing.get
|
||||
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 kotlinx.serialization.Serializable
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
data class SayHello @Inject constructor(
|
||||
private val publisher: Publisher,
|
||||
@Logger.MessageImpl private val messager: Logger
|
||||
) : Endpoint {
|
||||
override fun apply(route: Route) {
|
||||
route.route("/say_hello") {
|
||||
get {
|
||||
val rep = Rep(
|
||||
model = publisher.model,
|
||||
version = publisher.versionCode,
|
||||
snapshot = publisher.snapshot,
|
||||
abi = publisher.abi
|
||||
)
|
||||
call.respond(rep)
|
||||
}
|
||||
webSocket {
|
||||
val model = call.request.queryParameters["model"] ?: "?"
|
||||
val rep = with(publisher) { Rep(model, versionCode, snapshot, abi) }
|
||||
|
||||
messager.log("Connection from [$model]", Message.LEVEL_INFO)
|
||||
sendSerialized(rep)
|
||||
|
||||
for (frame in incoming) {
|
||||
when (frame) {
|
||||
is Frame.Text -> {
|
||||
messager.log("[$model] " + frame.readText(), Message.LEVEL_WARN)
|
||||
sendSerialized(rep)
|
||||
}
|
||||
|
||||
is Frame.Binary -> {
|
||||
|
||||
}
|
||||
is Frame.Close -> {
|
||||
messager.log("Connection lost from [$model], reason: ${frame.readReason()}")
|
||||
}
|
||||
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Keep
|
||||
@Serializable
|
||||
@Immutable
|
||||
data class Rep(
|
||||
val model: String,
|
||||
val version: Int,
|
||||
val snapshot: Boolean,
|
||||
val abi: Abi
|
||||
)
|
||||
}
|
@ -1,14 +1,15 @@
|
||||
package com.m3u.data.local.http.internal
|
||||
package com.m3u.data.television.http.internal
|
||||
|
||||
import com.m3u.data.local.http.HttpServer
|
||||
import com.m3u.data.local.http.endpoint.Playlists
|
||||
import com.m3u.data.local.http.endpoint.SayHello
|
||||
import com.m3u.data.television.http.HttpServer
|
||||
import com.m3u.data.television.http.endpoint.Playlists
|
||||
import com.m3u.data.television.http.endpoint.SayHello
|
||||
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.jetty.Jetty
|
||||
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
|
||||
@ -26,7 +27,7 @@ internal class HttpServerImpl @Inject constructor(
|
||||
private var server: EmbeddedServer<*, *>? = null
|
||||
|
||||
override fun start(port: Int) {
|
||||
server = embeddedServer(Jetty, port) {
|
||||
server = embeddedServer(Netty, port) {
|
||||
configureSerialization()
|
||||
configureSockets()
|
||||
configureCors()
|
||||
@ -57,6 +58,11 @@ internal class HttpServerImpl @Inject constructor(
|
||||
|
||||
private fun Application.configureSockets() {
|
||||
install(WebSockets) {
|
||||
val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
prettyPrint = true
|
||||
}
|
||||
contentConverter = KotlinxWebsocketSerializationConverter(json)
|
||||
pingPeriod = Duration.ofSeconds(15)
|
||||
timeout = Duration.ofSeconds(15)
|
||||
}
|
||||
@ -65,6 +71,7 @@ internal class HttpServerImpl @Inject constructor(
|
||||
private fun Application.configureCors() {
|
||||
install(CORS) {
|
||||
anyHost()
|
||||
allowSameOrigin = true
|
||||
}
|
||||
}
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.local.http.internal
|
||||
package com.m3u.data.television.http.internal
|
||||
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.local.nsd
|
||||
package com.m3u.data.television.nsd
|
||||
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
import kotlinx.coroutines.flow.Flow
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.local.nsd.internal
|
||||
package com.m3u.data.television.nsd.internal
|
||||
|
||||
import android.net.nsd.NsdManager
|
||||
import android.net.nsd.NsdServiceInfo
|
||||
@ -6,9 +6,9 @@ import com.m3u.core.architecture.dispatcher.Dispatcher
|
||||
import com.m3u.core.architecture.dispatcher.M3uDispatchers.IO
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.core.architecture.logger.prefix
|
||||
import com.m3u.data.local.nsd.NsdDeviceManager
|
||||
import com.m3u.data.local.nsd.NsdDeviceManager.Companion.META_DATA_PIN
|
||||
import com.m3u.data.local.nsd.NsdDeviceManager.Companion.SERVICE_TYPE
|
||||
import com.m3u.data.television.nsd.NsdDeviceManager
|
||||
import com.m3u.data.television.nsd.NsdDeviceManager.Companion.META_DATA_PIN
|
||||
import com.m3u.data.television.nsd.NsdDeviceManager.Companion.SERVICE_TYPE
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.channels.trySendBlocking
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.work
|
||||
package com.m3u.data.worker
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.work
|
||||
package com.m3u.data.worker
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
@ -1,4 +1,4 @@
|
||||
package com.m3u.data.work
|
||||
package com.m3u.data.worker
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
@ -13,7 +13,7 @@ import com.m3u.core.Contracts
|
||||
import com.m3u.core.architecture.pref.Pref
|
||||
import com.m3u.core.architecture.pref.observeAsFlow
|
||||
import com.m3u.data.database.model.Stream
|
||||
import com.m3u.data.local.service.PlayerManager
|
||||
import com.m3u.data.service.PlayerManager
|
||||
import com.m3u.data.repository.MediaRepository
|
||||
import com.m3u.data.repository.StreamRepository
|
||||
import com.m3u.data.repository.observeAll
|
||||
|
@ -13,11 +13,9 @@ import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Add
|
||||
@ -43,7 +41,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.m3u.core.architecture.pref.LocalPref
|
||||
import com.m3u.core.util.basic.title
|
||||
import com.m3u.data.database.model.Playlist
|
||||
import com.m3u.data.repository.PairState
|
||||
import com.m3u.data.repository.ConnectionToTelevision
|
||||
import com.m3u.features.foryou.components.ConnectBottomSheet
|
||||
import com.m3u.features.foryou.components.ForyouDialog
|
||||
import com.m3u.features.foryou.components.OnRename
|
||||
import com.m3u.features.foryou.components.OnUnsubscribe
|
||||
@ -57,12 +56,10 @@ import com.m3u.material.components.Background
|
||||
import com.m3u.material.components.Button
|
||||
import com.m3u.material.ktx.interceptVolumeEvent
|
||||
import com.m3u.material.ktx.isTelevision
|
||||
import com.m3u.material.ktx.minus
|
||||
import com.m3u.material.ktx.only
|
||||
import com.m3u.material.ktx.split
|
||||
import com.m3u.material.ktx.thenIf
|
||||
import com.m3u.material.model.LocalHazeState
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import com.m3u.ui.ConnectBottomSheet
|
||||
import com.m3u.ui.EventHandler
|
||||
import com.m3u.ui.FontFamilies
|
||||
import com.m3u.ui.ResumeEvent
|
||||
@ -100,19 +97,21 @@ fun ForyouRoute(
|
||||
|
||||
var isConnectSheetVisible by remember { mutableStateOf(false) }
|
||||
|
||||
val pinCodeForServer by viewModel.pinCodeForServer.collectAsStateWithLifecycle()
|
||||
val pairStateForClient by viewModel.pairStateForClient.collectAsStateWithLifecycle()
|
||||
// for televisions
|
||||
val broadcastCodeOnTelevision by viewModel.broadcastCodeOnTelevision.collectAsStateWithLifecycle()
|
||||
|
||||
val connecting by remember {
|
||||
derivedStateOf { pairStateForClient == PairState.Connecting }
|
||||
// for smartphones
|
||||
val connectionToTelevision by viewModel.connectionToTelevision.collectAsStateWithLifecycle()
|
||||
val connectedTelevision by viewModel.connectedTelevision.collectAsStateWithLifecycle()
|
||||
val searchingToTelevision by remember {
|
||||
derivedStateOf { connectionToTelevision is ConnectionToTelevision.Searching }
|
||||
}
|
||||
val connected by remember {
|
||||
derivedStateOf { pairStateForClient is PairState.Connected }
|
||||
val connectedToTelevision by remember {
|
||||
derivedStateOf { connectedTelevision != null }
|
||||
}
|
||||
|
||||
val sheetState = rememberModalBottomSheetState(
|
||||
skipPartiallyExpanded = true,
|
||||
confirmValueChange = { !connecting }
|
||||
confirmValueChange = { !searchingToTelevision }
|
||||
)
|
||||
|
||||
EventHandler(resume) {
|
||||
@ -128,8 +127,8 @@ fun ForyouRoute(
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
snapshotFlow { connected }.collectLatest { visible ->
|
||||
if (visible) {
|
||||
snapshotFlow { connectedToTelevision }.collectLatest { connected ->
|
||||
if (connected) {
|
||||
isConnectSheetVisible = false
|
||||
code = ""
|
||||
}
|
||||
@ -165,8 +164,9 @@ fun ForyouRoute(
|
||||
ConnectBottomSheet(
|
||||
sheetState = sheetState,
|
||||
visible = isConnectSheetVisible,
|
||||
connectedTelevision = connectedTelevision,
|
||||
code = code,
|
||||
connecting = connecting,
|
||||
connecting = searchingToTelevision,
|
||||
onCode = {
|
||||
code = it
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
@ -174,13 +174,12 @@ fun ForyouRoute(
|
||||
onDismissRequest = {
|
||||
isConnectSheetVisible = false
|
||||
},
|
||||
onConnect = {
|
||||
viewModel.pair(code.toInt())
|
||||
}
|
||||
onConnect = { viewModel.openTelevisionCodeOnSmartphone(code.toInt()) },
|
||||
onDisconnect = { viewModel.closeTelevisionCodeOnSmartphone() }
|
||||
)
|
||||
Crossfade(
|
||||
targetState = pinCodeForServer,
|
||||
label = "pin-code",
|
||||
targetState = broadcastCodeOnTelevision,
|
||||
label = "broadcast-code-on-television",
|
||||
modifier = Modifier
|
||||
.padding(spacing.medium)
|
||||
.align(Alignment.BottomEnd)
|
||||
@ -230,13 +229,10 @@ private fun ForyouScreen(
|
||||
) {
|
||||
val showRecommend = recommend.isNotEmpty()
|
||||
val showPlaylist = details.isNotEmpty()
|
||||
val (topContentPadding, otherContentPadding) =
|
||||
contentPadding split if (showRecommend) WindowInsetsSides.Top else null
|
||||
if (showRecommend) {
|
||||
Column {
|
||||
Spacer(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(contentPadding.calculateTopPadding())
|
||||
)
|
||||
Box(Modifier.padding(topContentPadding)) {
|
||||
RecommendGallery(
|
||||
recommend = recommend,
|
||||
navigateToStream = navigateToStream,
|
||||
@ -247,14 +243,12 @@ private fun ForyouScreen(
|
||||
}
|
||||
|
||||
if (showPlaylist) {
|
||||
val actualContentPadding = if (!showRecommend) contentPadding
|
||||
else contentPadding - contentPadding.only(WindowInsetsSides.Top)
|
||||
PlaylistGallery(
|
||||
rowCount = actualRowCount,
|
||||
details = details,
|
||||
navigateToPlaylist = navigateToPlaylist,
|
||||
onMenu = { dialog = ForyouDialog.Selections(it) },
|
||||
contentPadding = actualContentPadding,
|
||||
contentPadding = otherContentPadding,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.haze(
|
||||
|
@ -7,10 +7,10 @@ import com.m3u.core.architecture.dispatcher.M3uDispatchers.Default
|
||||
import com.m3u.core.architecture.dispatcher.M3uDispatchers.IO
|
||||
import com.m3u.core.architecture.pref.Pref
|
||||
import com.m3u.core.architecture.pref.observeAsFlow
|
||||
import com.m3u.data.repository.PairState
|
||||
import com.m3u.data.repository.ConnectionToTelevision
|
||||
import com.m3u.data.repository.PlaylistRepository
|
||||
import com.m3u.data.repository.StreamRepository
|
||||
import com.m3u.data.repository.TvRepository
|
||||
import com.m3u.data.repository.TelevisionRepository
|
||||
import com.m3u.features.foryou.components.recommend.Recommend
|
||||
import com.m3u.features.foryou.model.PlaylistDetail
|
||||
import com.m3u.features.foryou.model.toDetail
|
||||
@ -19,14 +19,13 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
@ -41,16 +40,14 @@ import kotlin.time.toDuration
|
||||
class ForyouViewModel @Inject constructor(
|
||||
private val playlistRepository: PlaylistRepository,
|
||||
streamRepository: StreamRepository,
|
||||
private val tvRepository: TvRepository,
|
||||
private val televisionRepository: TelevisionRepository,
|
||||
pref: Pref,
|
||||
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher,
|
||||
@Dispatcher(Default) defaultDispatcher: CoroutineDispatcher
|
||||
) : ViewModel() {
|
||||
internal val pinCodeForServer: StateFlow<String?> = tvRepository
|
||||
.pinCodeForServer
|
||||
.map { code ->
|
||||
code?.let { convertToPaddedString(it) }
|
||||
}
|
||||
internal val broadcastCodeOnTelevision: StateFlow<String?> = televisionRepository
|
||||
.broadcastCodeOnTelevision
|
||||
.map { code -> code?.let { convertToPaddedString(it) } }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
@ -60,6 +57,7 @@ class ForyouViewModel @Inject constructor(
|
||||
private val counts: StateFlow<Map<String, Int>> = streamRepository
|
||||
.observeAll()
|
||||
.map { streams ->
|
||||
// map playlistUrl to count
|
||||
streams
|
||||
.groupBy { it.playlistUrl }
|
||||
.mapValues { it.value.size }
|
||||
@ -73,10 +71,11 @@ class ForyouViewModel @Inject constructor(
|
||||
internal val details: StateFlow<ImmutableList<PlaylistDetail>> = playlistRepository
|
||||
.observeAll()
|
||||
.distinctUntilChanged()
|
||||
.combine(counts) { fs, cs ->
|
||||
.combine(counts) { playlists, counts ->
|
||||
withContext(defaultDispatcher) {
|
||||
fs.map { f ->
|
||||
f.toDetail(cs[f.url] ?: PlaylistDetail.DEFAULT_COUNT)
|
||||
playlists.map { playlist ->
|
||||
val count = counts[playlist.url] ?: PlaylistDetail.DEFAULT_COUNT
|
||||
playlist.toDetail(count)
|
||||
}
|
||||
}
|
||||
.toPersistentList()
|
||||
@ -96,7 +95,6 @@ class ForyouViewModel @Inject constructor(
|
||||
initialValue = Duration.INFINITE
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal val recommend: StateFlow<Recommend> = unseensDuration
|
||||
.flatMapLatest { streamRepository.observeAllUnseenFavourites(it) }
|
||||
.map { prev -> Recommend(prev.map { Recommend.UnseenSpec(it) }) }
|
||||
@ -118,24 +116,34 @@ class ForyouViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private val pinCodeForClient = MutableSharedFlow<Int?>()
|
||||
private val televisionCodeOnSmartphone = MutableSharedFlow<Int?>()
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal val pairStateForClient: StateFlow<PairState> =
|
||||
pinCodeForClient.flatMapLatest { pinCode ->
|
||||
if (pinCode != null) tvRepository.pairForClient(pinCode)
|
||||
else flow { }
|
||||
internal val connectionToTelevision: StateFlow<ConnectionToTelevision> =
|
||||
televisionCodeOnSmartphone.flatMapLatest { code ->
|
||||
if (code != null) televisionRepository.connectToTelevision(code)
|
||||
else {
|
||||
televisionRepository.disconnectToTelevision()
|
||||
flowOf(ConnectionToTelevision.Idle())
|
||||
}
|
||||
}
|
||||
.flowOn(ioDispatcher)
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
initialValue = PairState.Idle,
|
||||
initialValue = ConnectionToTelevision.Idle(),
|
||||
started = SharingStarted.WhileSubscribed(5_000)
|
||||
)
|
||||
|
||||
internal fun pair(pin: Int) {
|
||||
internal val connectedTelevision = televisionRepository.connectedTelevision
|
||||
|
||||
internal fun openTelevisionCodeOnSmartphone(code: Int) {
|
||||
viewModelScope.launch {
|
||||
pinCodeForClient.emit(pin)
|
||||
televisionCodeOnSmartphone.emit(code)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun closeTelevisionCodeOnSmartphone() {
|
||||
viewModelScope.launch {
|
||||
televisionCodeOnSmartphone.emit(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -0,0 +1,93 @@
|
||||
package com.m3u.features.foryou.components
|
||||
|
||||
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.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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,125 @@
|
||||
package com.m3u.features.foryou.components
|
||||
|
||||
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.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ModalBottomSheetDefaults
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
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.data.television.http.endpoint.SayHello
|
||||
import com.m3u.i18n.R
|
||||
|
||||
@Composable
|
||||
internal fun ConnectBottomSheet(
|
||||
visible: Boolean,
|
||||
connecting: Boolean,
|
||||
connectedTelevision: SayHello.Rep?,
|
||||
code: String,
|
||||
sheetState: SheetState,
|
||||
onCode: (String) -> Unit,
|
||||
onConnect: () -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (visible) {
|
||||
ModalBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = {
|
||||
if (!connecting) onDismissRequest()
|
||||
},
|
||||
windowInsets = WindowInsets(0),
|
||||
properties = ModalBottomSheetDefaults.properties(
|
||||
shouldDismissOnBackPress = false
|
||||
)
|
||||
) {
|
||||
Column(
|
||||
modifier.padding(WindowInsets.navigationBarsIgnoringVisibility.asPaddingValues())
|
||||
) {
|
||||
val title = when {
|
||||
connectedTelevision != null -> connectedTelevision.model
|
||||
else -> stringResource(R.string.feat_foryou_connect_title).title()
|
||||
}
|
||||
val subtitle = stringResource(R.string.feat_foryou_connect_subtitle)
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
if (connectedTelevision == null) {
|
||||
Text(
|
||||
text = subtitle,
|
||||
modifier = Modifier.padding(16.dp, 8.dp, 16.dp, 0.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.78f)
|
||||
)
|
||||
CodeRow(
|
||||
code = code,
|
||||
length = 6,
|
||||
onClick = {}
|
||||
)
|
||||
}
|
||||
TextButton(
|
||||
enabled = connectedTelevision != null || (!connecting && code.length == 6),
|
||||
onClick = when {
|
||||
connectedTelevision != null -> onDisconnect
|
||||
else -> onConnect
|
||||
},
|
||||
modifier = Modifier.padding(top = 4.dp)
|
||||
) {
|
||||
Text(
|
||||
when {
|
||||
connectedTelevision != null -> "DISCONNECT"
|
||||
connecting -> "CONNECTING"
|
||||
else -> "CONNECT"
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = connectedTelevision == null,
|
||||
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }) + fadeOut()
|
||||
) {
|
||||
VirtualNumberKeyboard(
|
||||
code = code,
|
||||
onCode = onCode,
|
||||
modifier = Modifier.padding(top = 16.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -1,26 +1,14 @@
|
||||
package com.m3u.material.components
|
||||
package com.m3u.features.foryou.components
|
||||
|
||||
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.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.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBarsIgnoringVisibility
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
@ -36,111 +24,11 @@ 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.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
|
||||
@Composable
|
||||
fun CodeSkeleton(
|
||||
title: String,
|
||||
subtitle: String,
|
||||
code: String,
|
||||
onCode: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
loading: Boolean = false,
|
||||
keyboard: Boolean = true,
|
||||
onKeyboard: () -> Unit = {},
|
||||
onSubmit: () -> Unit
|
||||
) {
|
||||
Column(modifier) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Top
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = subtitle,
|
||||
modifier = Modifier.padding(16.dp, 8.dp, 16.dp, 0.dp),
|
||||
textAlign = TextAlign.Center,
|
||||
fontSize = 14.sp,
|
||||
lineHeight = 16.sp,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(0.78f)
|
||||
)
|
||||
|
||||
CodeRow(
|
||||
code = code,
|
||||
length = 6,
|
||||
onClick = onKeyboard
|
||||
)
|
||||
|
||||
TextButton(
|
||||
modifier = Modifier.padding(top = 4.dp, bottom = 16.dp),
|
||||
enabled = !loading && code.length == 6,
|
||||
onClick = {
|
||||
onSubmit()
|
||||
},
|
||||
) {
|
||||
Text(if (loading) "CONNECTING" else "CONNECT")
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = keyboard,
|
||||
enter = slideInVertically(initialOffsetY = { fullHeight -> fullHeight }) + fadeIn(),
|
||||
exit = slideOutVertically(targetOffsetY = { fullHeight -> fullHeight }) + fadeOut()
|
||||
) {
|
||||
VirtualNumberKeyboard(
|
||||
code = code,
|
||||
onCode = onCode,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
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
|
||||
fun VirtualNumberKeyboard(
|
||||
internal fun VirtualNumberKeyboard(
|
||||
modifier: Modifier = Modifier,
|
||||
code: String,
|
||||
onCode: (String) -> Unit,
|
||||
@ -150,7 +38,6 @@ fun VirtualNumberKeyboard(
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.background(MaterialTheme.colorScheme.onSurface.copy(.1f))
|
||||
.padding(WindowInsets.navigationBarsIgnoringVisibility.asPaddingValues())
|
||||
) {
|
||||
Row(
|
||||
Modifier.fillMaxWidth()
|
||||
@ -288,38 +175,3 @@ private fun KeyboardKey(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -6,7 +6,7 @@ import android.content.Context
|
||||
sealed interface PlaylistEvent {
|
||||
data object Refresh : PlaylistEvent
|
||||
data class Favourite(val id: Int, val target: Boolean) : PlaylistEvent
|
||||
data class Ban(val id: Int) : PlaylistEvent
|
||||
data class Hide(val id: Int) : PlaylistEvent
|
||||
data class SavePicture(val id: Int) : PlaylistEvent
|
||||
data object ScrollUp : PlaylistEvent
|
||||
data class Query(val text: String) : PlaylistEvent
|
||||
|
@ -132,7 +132,7 @@ internal fun PlaylistRoute(
|
||||
onRefresh = { viewModel.onEvent(PlaylistEvent.Refresh) },
|
||||
contentPadding = contentPadding,
|
||||
onFavorite = { id, target -> viewModel.onEvent(PlaylistEvent.Favourite(id, target)) },
|
||||
ban = { id -> viewModel.onEvent(PlaylistEvent.Ban(id)) },
|
||||
hide = { id -> viewModel.onEvent(PlaylistEvent.Hide(id)) },
|
||||
savePicture = {
|
||||
if (writeExternalPermissionRequired && writeExternalPermissionState.status is PermissionStatus.Denied) {
|
||||
writeExternalPermissionState.launchPermissionRequest()
|
||||
@ -175,7 +175,7 @@ private fun PlaylistScreen(
|
||||
navigateToStream: () -> Unit,
|
||||
onScrollUp: () -> Unit,
|
||||
onFavorite: (streamId: Int, target: Boolean) -> Unit,
|
||||
ban: (streamId: Int) -> Unit,
|
||||
hide: (streamId: Int) -> Unit,
|
||||
savePicture: (streamId: Int) -> Unit,
|
||||
createShortcut: (streamId: Int) -> Unit,
|
||||
contentPadding: PaddingValues,
|
||||
@ -226,7 +226,7 @@ private fun PlaylistScreen(
|
||||
sort = sort,
|
||||
onSort = onSort,
|
||||
onFavorite = onFavorite,
|
||||
ban = ban,
|
||||
hide = hide,
|
||||
onSavePicture = savePicture,
|
||||
createShortcut = createShortcut,
|
||||
modifier = modifier
|
||||
@ -243,7 +243,7 @@ private fun PlaylistScreen(
|
||||
sorts = sorts,
|
||||
sort = sort,
|
||||
onFavorite = onFavorite,
|
||||
ban = ban,
|
||||
hide = hide,
|
||||
savePicture = savePicture,
|
||||
createShortcut = createShortcut,
|
||||
modifier = modifier
|
||||
|
@ -22,8 +22,8 @@ import com.m3u.core.wrapper.Resource
|
||||
import com.m3u.core.wrapper.eventOf
|
||||
import com.m3u.data.database.model.Playlist
|
||||
import com.m3u.data.database.model.Stream
|
||||
import com.m3u.data.local.service.MessageManager
|
||||
import com.m3u.data.local.service.PlayerManager
|
||||
import com.m3u.data.service.MessageManager
|
||||
import com.m3u.data.service.PlayerManager
|
||||
import com.m3u.data.repository.MediaRepository
|
||||
import com.m3u.data.repository.PlaylistRepository
|
||||
import com.m3u.data.repository.StreamRepository
|
||||
@ -35,7 +35,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@ -59,7 +58,7 @@ class PlaylistViewModel @Inject constructor(
|
||||
private val mediaRepository: MediaRepository,
|
||||
playerManager: PlayerManager,
|
||||
private val pref: Pref,
|
||||
@Logger.Message private val logger: Logger,
|
||||
@Logger.MessageImpl private val logger: Logger,
|
||||
private val messageManager: MessageManager
|
||||
) : BaseViewModel<PlaylistState, PlaylistEvent>(
|
||||
emptyState = PlaylistState()
|
||||
@ -72,7 +71,7 @@ class PlaylistViewModel @Inject constructor(
|
||||
PlaylistEvent.Refresh -> refresh()
|
||||
is PlaylistEvent.Favourite -> favourite(event)
|
||||
PlaylistEvent.ScrollUp -> scrollUp()
|
||||
is PlaylistEvent.Ban -> ban(event)
|
||||
is PlaylistEvent.Hide -> hide(event)
|
||||
is PlaylistEvent.SavePicture -> savePicture(event)
|
||||
is PlaylistEvent.Query -> query(event)
|
||||
is PlaylistEvent.CreateShortcut -> createShortcut(event.context, event.id)
|
||||
@ -165,14 +164,14 @@ class PlaylistViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun ban(event: PlaylistEvent.Ban) {
|
||||
private fun hide(event: PlaylistEvent.Hide) {
|
||||
viewModelScope.launch {
|
||||
val id = event.id
|
||||
val stream = streamRepository.get(id)
|
||||
if (stream == null) {
|
||||
messageManager.emit(PlaylistMessage.StreamNotFound)
|
||||
} else {
|
||||
streamRepository.ban(stream.id, true)
|
||||
streamRepository.hide(stream.id, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -245,14 +244,13 @@ class PlaylistViewModel @Inject constructor(
|
||||
started = SharingStarted.WhileSubscribed(5_000L)
|
||||
)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val unsorted: StateFlow<List<Stream>> = combine(
|
||||
playlistUrl.flatMapLatest { url ->
|
||||
playlistRepository.observeWithStreams(url)
|
||||
},
|
||||
query
|
||||
) { current, query ->
|
||||
current?.streams?.filter { !it.banned && it.title.contains(query, true) } ?: emptyList()
|
||||
current?.streams?.filter { !it.hidden && it.title.contains(query, true) } ?: emptyList()
|
||||
}
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
|
@ -33,8 +33,8 @@ import com.m3u.core.unspecified.unspecifiable
|
||||
import com.m3u.core.util.context.isDarkMode
|
||||
import com.m3u.core.util.context.isPortraitMode
|
||||
import com.m3u.core.wrapper.Message
|
||||
import com.m3u.data.local.service.MessageManager
|
||||
import com.m3u.data.local.service.PlayerManager
|
||||
import com.m3u.data.service.MessageManager
|
||||
import com.m3u.data.service.PlayerManager
|
||||
import com.m3u.ui.Toolkit
|
||||
import com.m3u.ui.helper.Action
|
||||
import com.m3u.ui.helper.Fob
|
||||
@ -62,7 +62,7 @@ class TvPlaylistActivity : AppCompatActivity() {
|
||||
lateinit var pref: Pref
|
||||
|
||||
@Inject
|
||||
@Logger.Message
|
||||
@Logger.MessageImpl
|
||||
lateinit var logger: Logger
|
||||
|
||||
@Inject
|
||||
|
@ -15,7 +15,7 @@ internal fun PlaylistDialog(
|
||||
status: DialogStatus,
|
||||
onUpdate: (DialogStatus) -> Unit,
|
||||
onFavorite: (streamId: Int, target: Boolean) -> Unit,
|
||||
ban: (streamId: Int) -> Unit,
|
||||
hide: (streamId: Int) -> Unit,
|
||||
onSavePicture: (streamId: Int) -> Unit,
|
||||
createShortcut: (streamId: Int) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
@ -40,9 +40,9 @@ internal fun PlaylistDialog(
|
||||
onUpdate(DialogStatus.Idle)
|
||||
onFavorite(status.stream.id, !favourite)
|
||||
}
|
||||
DialogItem(string.feat_playlist_dialog_mute_title) {
|
||||
DialogItem(string.feat_playlist_dialog_hide_title) {
|
||||
onUpdate(DialogStatus.Idle)
|
||||
ban(status.stream.id)
|
||||
hide(status.stream.id)
|
||||
}
|
||||
if (!status.stream.cover.isNullOrEmpty()) {
|
||||
DialogItem(string.feat_playlist_dialog_save_picture_title) {
|
||||
|
@ -97,7 +97,7 @@ internal object PlaylistDrawerDefaults {
|
||||
fun rememberStreamMenuItems(
|
||||
stream: Stream?,
|
||||
onFavorite: (streamId: Int, target: Boolean) -> Unit,
|
||||
ban: (streamId: Int) -> Unit,
|
||||
hide: (streamId: Int) -> Unit,
|
||||
createShortcut: (streamId: Int) -> Unit,
|
||||
savePicture: (streamId: Int) -> Unit,
|
||||
): ImmutableList<DrawerItem> {
|
||||
@ -105,10 +105,10 @@ internal object PlaylistDrawerDefaults {
|
||||
if (stream?.favourite == true) string.feat_playlist_dialog_favourite_cancel_title
|
||||
else string.feat_playlist_dialog_favourite_title
|
||||
)
|
||||
val banTitle = stringResource(string.feat_playlist_dialog_mute_title)
|
||||
val hideTitle = stringResource(string.feat_playlist_dialog_hide_title)
|
||||
val createShortcutTitle = stringResource(string.feat_playlist_dialog_create_shortcut_title)
|
||||
val savePictureTitle = stringResource(string.feat_playlist_dialog_save_picture_title)
|
||||
return remember(stream, favouriteTitle, banTitle, createShortcutTitle, savePictureTitle) {
|
||||
return remember(stream, favouriteTitle, hideTitle, createShortcutTitle, savePictureTitle) {
|
||||
persistentListOf(
|
||||
DrawerItem(
|
||||
title = favouriteTitle,
|
||||
@ -119,10 +119,10 @@ internal object PlaylistDrawerDefaults {
|
||||
}
|
||||
),
|
||||
DrawerItem(
|
||||
title = banTitle,
|
||||
title = hideTitle,
|
||||
icon = Icons.Rounded.Delete,
|
||||
onClick = {
|
||||
stream?.let { ban(it.id) }
|
||||
stream?.let { hide(it.id) }
|
||||
true
|
||||
}
|
||||
),
|
||||
|
@ -88,7 +88,7 @@ internal fun PlaylistScreenImpl(
|
||||
navigateToStream: () -> Unit,
|
||||
onRefresh: () -> Unit,
|
||||
onFavorite: (streamId: Int, target: Boolean) -> Unit,
|
||||
ban: (streamId: Int) -> Unit,
|
||||
hide: (streamId: Int) -> Unit,
|
||||
onSavePicture: (streamId: Int) -> Unit,
|
||||
createShortcut: (streamId: Int) -> Unit,
|
||||
isAtTopState: MutableState<Boolean>,
|
||||
@ -251,7 +251,7 @@ internal fun PlaylistScreenImpl(
|
||||
status = dialogStatus,
|
||||
onUpdate = { dialogStatus = it },
|
||||
onFavorite = onFavorite,
|
||||
ban = ban,
|
||||
hide = hide,
|
||||
onSavePicture = onSavePicture,
|
||||
createShortcut = createShortcut
|
||||
)
|
||||
|
@ -44,7 +44,7 @@ internal fun TvPlaylistScreenImpl(
|
||||
sorts: ImmutableList<Sort>,
|
||||
sort: Sort,
|
||||
onFavorite: (streamId: Int, target: Boolean) -> Unit,
|
||||
ban: (streamId: Int) -> Unit,
|
||||
hide: (streamId: Int) -> Unit,
|
||||
savePicture: (streamId: Int) -> Unit,
|
||||
createShortcut: (streamId: Int) -> Unit,
|
||||
navigateToStream: () -> Unit,
|
||||
@ -75,7 +75,7 @@ internal fun TvPlaylistScreenImpl(
|
||||
items = PlaylistDrawerDefaults.rememberStreamMenuItems(
|
||||
stream = press,
|
||||
onFavorite = onFavorite,
|
||||
ban = ban,
|
||||
hide = hide,
|
||||
createShortcut = createShortcut,
|
||||
savePicture = savePicture
|
||||
)
|
||||
|
@ -6,7 +6,7 @@ sealed interface SettingEvent {
|
||||
data object Subscribe : SettingEvent
|
||||
data class OnTitle(val title: String) : SettingEvent
|
||||
data class OnUrl(val url: String) : SettingEvent
|
||||
data class OnBanned(val id: Int) : SettingEvent
|
||||
data class OnHidden(val id: Int) : SettingEvent
|
||||
data class OpenDocument(val uri: Uri = Uri.EMPTY) : SettingEvent
|
||||
data object OnLocalStorage : SettingEvent
|
||||
}
|
@ -77,7 +77,7 @@ fun SettingRoute(
|
||||
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val packs by viewModel.packs.collectAsStateWithLifecycle()
|
||||
val banneds by viewModel.banneds.collectAsStateWithLifecycle()
|
||||
val hiddenStreams by viewModel.hiddenStreams.collectAsStateWithLifecycle()
|
||||
val backingUpOrRestoring by viewModel.backingUpOrRestoring.collectAsStateWithLifecycle()
|
||||
|
||||
val helper = LocalHelper.current
|
||||
@ -121,14 +121,14 @@ fun SettingRoute(
|
||||
url = state.url,
|
||||
backingUpOrRestoring = backingUpOrRestoring,
|
||||
uriWrapper = rememberUriWrapper(state.uri),
|
||||
banneds = banneds,
|
||||
hiddenStreams = hiddenStreams,
|
||||
onTitle = { viewModel.onEvent(SettingEvent.OnTitle(it)) },
|
||||
onUrl = { viewModel.onEvent(SettingEvent.OnUrl(it)) },
|
||||
onSubscribe = {
|
||||
controller?.hide()
|
||||
viewModel.onEvent(SettingEvent.Subscribe)
|
||||
},
|
||||
onBanned = { viewModel.onEvent(SettingEvent.OnBanned(it)) },
|
||||
onHidden = { viewModel.onEvent(SettingEvent.OnHidden(it)) },
|
||||
navigateToAbout = navigateToAbout,
|
||||
localStorage = state.localStorage,
|
||||
onLocalStorage = { viewModel.onEvent(SettingEvent.OnLocalStorage) },
|
||||
@ -172,8 +172,8 @@ private fun SettingScreen(
|
||||
onTitle: (String) -> Unit,
|
||||
onUrl: (String) -> Unit,
|
||||
onSubscribe: () -> Unit,
|
||||
banneds: ImmutableList<Stream>,
|
||||
onBanned: (Int) -> Unit,
|
||||
hiddenStreams: ImmutableList<Stream>,
|
||||
onHidden: (Int) -> Unit,
|
||||
navigateToAbout: () -> Unit,
|
||||
localStorage: Boolean,
|
||||
onLocalStorage: () -> Unit,
|
||||
@ -261,8 +261,8 @@ private fun SettingScreen(
|
||||
url = url,
|
||||
uriWrapper = uriWrapper,
|
||||
backingUpOrRestoring = backingUpOrRestoring,
|
||||
banneds = banneds,
|
||||
onBanned = onBanned,
|
||||
hiddenStreams = hiddenStreams,
|
||||
onHidden = onHidden,
|
||||
onTitle = onTitle,
|
||||
onUrl = onUrl,
|
||||
onSubscribe = onSubscribe,
|
||||
|
@ -20,18 +20,17 @@ import com.m3u.data.api.LocalPreparedService
|
||||
import com.m3u.data.database.dao.ColorPackDao
|
||||
import com.m3u.data.database.model.ColorPack
|
||||
import com.m3u.data.database.model.Stream
|
||||
import com.m3u.data.local.service.MessageManager
|
||||
import com.m3u.data.service.MessageManager
|
||||
import com.m3u.data.repository.StreamRepository
|
||||
import com.m3u.data.repository.observeAll
|
||||
import com.m3u.data.work.BackupWorker
|
||||
import com.m3u.data.work.RestoreWorker
|
||||
import com.m3u.data.work.SubscriptionWorker
|
||||
import com.m3u.data.worker.BackupWorker
|
||||
import com.m3u.data.worker.RestoreWorker
|
||||
import com.m3u.data.worker.SubscriptionWorker
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
@ -60,8 +59,8 @@ class SettingViewModel @Inject constructor(
|
||||
)
|
||||
) {
|
||||
internal var subscribeForTv by mutableStateOf(false)
|
||||
internal val banneds: StateFlow<ImmutableList<Stream>> = streamRepository
|
||||
.observeAll { it.banned }
|
||||
internal val hiddenStreams: StateFlow<ImmutableList<Stream>> = streamRepository
|
||||
.observeAll { it.hidden }
|
||||
.map { it.toImmutableList() }
|
||||
.stateIn(
|
||||
scope = viewModelScope,
|
||||
@ -83,7 +82,7 @@ class SettingViewModel @Inject constructor(
|
||||
SettingEvent.Subscribe -> subscribe()
|
||||
is SettingEvent.OnTitle -> onTitle(event.title)
|
||||
is SettingEvent.OnUrl -> onUrl(event.url)
|
||||
is SettingEvent.OnBanned -> onBanned(event.id)
|
||||
is SettingEvent.OnHidden -> onHidden(event.id)
|
||||
SettingEvent.OnLocalStorage -> onLocalStorage()
|
||||
is SettingEvent.OpenDocument -> openDocument(event.uri)
|
||||
}
|
||||
@ -107,11 +106,11 @@ class SettingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun onBanned(streamId: Int) {
|
||||
val banned = banneds.value.find { it.id == streamId }
|
||||
if (banned != null) {
|
||||
private fun onHidden(streamId: Int) {
|
||||
val hidden = hiddenStreams.value.find { it.id == streamId }
|
||||
if (hidden != null) {
|
||||
viewModelScope.launch {
|
||||
streamRepository.ban(streamId, false)
|
||||
streamRepository.hide(streamId, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -194,7 +193,6 @@ class SettingViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
internal val backingUpOrRestoring: StateFlow<BackingUpAndRestoringState> = workManager
|
||||
.getWorkInfosFlow(
|
||||
WorkQuery.fromStates(
|
||||
|
@ -12,9 +12,9 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import com.m3u.data.database.model.Stream
|
||||
|
||||
@Composable
|
||||
internal fun BannedStreamItem(
|
||||
internal fun hiddenStreamstreamItem(
|
||||
stream: Stream,
|
||||
onBanned: () -> Unit,
|
||||
onHidden: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
ListItem(
|
||||
@ -42,7 +42,7 @@ internal fun BannedStreamItem(
|
||||
enabled = true,
|
||||
onClickLabel = null,
|
||||
role = Role.Button,
|
||||
onClick = onBanned
|
||||
onClick = onHidden
|
||||
)
|
||||
.then(modifier)
|
||||
)
|
||||
|
@ -26,7 +26,7 @@ import com.m3u.core.architecture.pref.LocalPref
|
||||
import com.m3u.data.database.model.Stream
|
||||
import com.m3u.features.setting.BackingUpAndRestoringState
|
||||
import com.m3u.features.setting.UriWrapper
|
||||
import com.m3u.features.setting.components.BannedStreamItem
|
||||
import com.m3u.features.setting.components.hiddenStreamstreamItem
|
||||
import com.m3u.features.setting.components.LocalStorageButton
|
||||
import com.m3u.features.setting.components.LocalStorageSwitch
|
||||
import com.m3u.features.setting.components.RemoteControlSubscribeSwitch
|
||||
@ -48,8 +48,8 @@ internal fun SubscriptionsFragment(
|
||||
localStorage: Boolean,
|
||||
subscribeForTv: Boolean,
|
||||
backingUpOrRestoring: BackingUpAndRestoringState,
|
||||
banneds: ImmutableList<Stream>,
|
||||
onBanned: (Int) -> Unit,
|
||||
hiddenStreams: ImmutableList<Stream>,
|
||||
onHidden: (Int) -> Unit,
|
||||
onTitle: (String) -> Unit,
|
||||
onUrl: (String) -> Unit,
|
||||
onClipboard: (String) -> Unit,
|
||||
@ -75,13 +75,13 @@ internal fun SubscriptionsFragment(
|
||||
verticalArrangement = Arrangement.spacedBy(spacing.small),
|
||||
modifier = modifier
|
||||
) {
|
||||
if (banneds.isNotEmpty()) {
|
||||
if (hiddenStreams.isNotEmpty()) {
|
||||
item {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(string.feat_setting_label_muted_streams),
|
||||
text = stringResource(string.feat_setting_label_hidden_streams),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -92,10 +92,10 @@ internal fun SubscriptionsFragment(
|
||||
horizontal = spacing.medium
|
||||
)
|
||||
)
|
||||
banneds.forEach { stream ->
|
||||
BannedStreamItem(
|
||||
hiddenStreams.forEach { stream ->
|
||||
hiddenStreamstreamItem(
|
||||
stream = stream,
|
||||
onBanned = { onBanned(stream.id) }
|
||||
onHidden = { onHidden(stream.id) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -32,8 +32,8 @@ import com.m3u.core.util.basic.rational
|
||||
import com.m3u.core.util.context.isDarkMode
|
||||
import com.m3u.core.util.context.isPortraitMode
|
||||
import com.m3u.core.wrapper.Message
|
||||
import com.m3u.data.local.service.MessageManager
|
||||
import com.m3u.data.local.service.PlayerManager
|
||||
import com.m3u.data.service.MessageManager
|
||||
import com.m3u.data.service.PlayerManager
|
||||
import com.m3u.data.repository.StreamRepository
|
||||
import com.m3u.ui.Toolkit
|
||||
import com.m3u.ui.helper.Action
|
||||
@ -68,7 +68,7 @@ class PlayerActivity : ComponentActivity() {
|
||||
lateinit var pref: Pref
|
||||
|
||||
@Inject
|
||||
@Logger.Message
|
||||
@Logger.MessageImpl
|
||||
lateinit var messager: Logger
|
||||
|
||||
@Inject
|
||||
|
@ -6,7 +6,7 @@ import androidx.media3.common.Tracks
|
||||
import com.m3u.core.architecture.logger.Logger
|
||||
import com.m3u.core.architecture.logger.prefix
|
||||
import com.m3u.core.architecture.viewmodel.BaseViewModel
|
||||
import com.m3u.data.local.service.PlayerManager
|
||||
import com.m3u.data.service.PlayerManager
|
||||
import com.m3u.data.repository.PlaylistRepository
|
||||
import com.m3u.data.repository.StreamRepository
|
||||
import com.m3u.dlna.DLNACastManager
|
||||
|
@ -125,11 +125,10 @@ com-squareup-retrofit2-retrofit = { group = "com.squareup.retrofit2", name = "re
|
||||
com-squareup-leakcanary-leakcanary-android = { group = "com.squareup.leakcanary", name = "leakcanary-android", version.ref = "squareup-leakcanary" }
|
||||
|
||||
ktor-server-netty = { group = "io.ktor", name = "ktor-server-netty", version.ref = "ktor-server" }
|
||||
ktor-server-jetty = { group = "io.ktor", name = "ktor-server-jetty", version.ref = "ktor-server" }
|
||||
ktor-server-websockets = { group = "io.ktor", name = "ktor-server-websockets-jvm", version.ref = "ktor-server" }
|
||||
ktor-server-cors = { group = "io.ktor", name = "ktor-server-cors", version.ref = "ktor-server" }
|
||||
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation-jvm", version.ref = "ktor-server" }
|
||||
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json-jvm", version.ref = "ktor-server" }
|
||||
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor-server" }
|
||||
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor-server" }
|
||||
|
||||
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
|
||||
kotlinx-serialization-coverter-retrofit = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "kotlinx-serialization-converter-retrofit" }
|
||||
|
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="feat_foryou_muted_streams_playlist">directos bloqueados</string>
|
||||
<string name="feat_foryou_hidden_streams_playlist">directos bloqueados</string>
|
||||
<string name="feat_foryou_unsubscribe_playlist">desuscribirse</string>
|
||||
<string name="feat_foryou_copy_playlist_url">copiar url</string>
|
||||
<string name="feat_foryou_rename_playlist">renombrar</string>
|
||||
|
@ -1,10 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="feat_playlist_scheme_unknown">desconocido</string>
|
||||
<string name="feat_playlist_dialog_menu_title">¿deseas bloquear este directo?</string>
|
||||
<string name="feat_playlist_dialog_favourite_title">marcar favorito</string>
|
||||
<string name="feat_playlist_dialog_favourite_cancel_title">quitar favorito</string>
|
||||
<string name="feat_playlist_dialog_mute_title">bloquear</string>
|
||||
<string name="feat_playlist_dialog_hide_title">bloquear</string>
|
||||
<string name="feat_playlist_dialog_save_picture_title">guardar a álbum</string>
|
||||
<string name="feat_playlist_dialog_create_shortcut_title">crear atajo</string>
|
||||
<string name="feat_playlist_error_playlist_not_found">la playlist no existe (%s)</string>
|
||||
|
@ -17,7 +17,7 @@
|
||||
<string name="feat_setting_god_mode_description">ajusta la distribución con los botones del volumen</string>
|
||||
<string name="feat_setting_experimental_mode">modo experimental</string>
|
||||
<string name="feat_setting_experimental_mode_description">acciones no estables que traerían resultados inesperados</string>
|
||||
<string name="feat_setting_label_muted_streams">directos bloqueados</string>
|
||||
<string name="feat_setting_label_hidden_streams">directos bloqueados</string>
|
||||
<string name="feat_setting_label_add_playlist">añadir playlist</string>
|
||||
<string name="feat_setting_label_parse_from_clipboard">obtener del portapapeles</string>
|
||||
<string name="feat_setting_label_select_from_local_storage">seleccionar archivo</string>
|
||||
|
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="feat_foryou_muted_streams_playlist">屏蔽的频道</string>
|
||||
<string name="feat_foryou_hidden_streams_playlist">屏蔽的频道</string>
|
||||
<string name="feat_foryou_unsubscribe_playlist">取消订阅</string>
|
||||
<string name="feat_foryou_copy_playlist_url">复制链接</string>
|
||||
<string name="feat_foryou_rename_playlist">重命名</string>
|
||||
|
@ -1,10 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="feat_playlist_scheme_unknown">未知</string>
|
||||
<string name="feat_playlist_dialog_menu_title">屏蔽这个频道?</string>
|
||||
<string name="feat_playlist_dialog_favourite_title">喜欢</string>
|
||||
<string name="feat_playlist_dialog_favourite_cancel_title">取消喜欢</string>
|
||||
<string name="feat_playlist_dialog_mute_title">屏蔽</string>
|
||||
<string name="feat_playlist_dialog_hide_title">屏蔽</string>
|
||||
<string name="feat_playlist_dialog_save_picture_title">保存封面</string>
|
||||
<string name="feat_playlist_dialog_create_shortcut_title">创建快捷方式</string>
|
||||
|
||||
|
@ -20,7 +20,7 @@
|
||||
<string name="feat_setting_god_mode_description">通过物理按键调节界面</string>
|
||||
<string name="feat_setting_experimental_mode">试验特性</string>
|
||||
<string name="feat_setting_experimental_mode_description">不稳定的功能可能引发致命错误</string>
|
||||
<string name="feat_setting_label_muted_streams">屏蔽的频道</string>
|
||||
<string name="feat_setting_label_hidden_streams">屏蔽的频道</string>
|
||||
<string name="feat_setting_label_add_playlist">新增订阅</string>
|
||||
<string name="feat_setting_label_parse_from_clipboard">解析剪贴板</string>
|
||||
<string name="feat_setting_label_select_from_local_storage">选择文件</string>
|
||||
@ -89,6 +89,8 @@
|
||||
<string name="feat_setting_remote_control_tv_side">遥控器</string>
|
||||
<string name="feat_setting_remote_control_tv_side_description">允许智能手机控制你的电视</string>
|
||||
|
||||
<string name="feat_setting_subscribe_for_tv">为电视订阅</string>
|
||||
|
||||
<string name="feat_setting_backing_up">备份所有播放列表和频道中</string>
|
||||
<string name="feat_setting_restoring">恢复所有播放列表和频道中</string>
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="feat_foryou_muted_streams_playlist">banned streams</string>
|
||||
<string name="feat_foryou_hidden_streams_playlist">hidden streams</string>
|
||||
<string name="feat_foryou_unsubscribe_playlist">unsubscribe</string>
|
||||
<string name="feat_foryou_copy_playlist_url">copy url</string>
|
||||
<string name="feat_foryou_rename_playlist">rename</string>
|
||||
|
@ -1,10 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="feat_playlist_scheme_unknown">unknown</string>
|
||||
<string name="feat_playlist_dialog_menu_title">do you want to ban this stream?</string>
|
||||
<string name="feat_playlist_dialog_favourite_title">like</string>
|
||||
<string name="feat_playlist_dialog_favourite_cancel_title">cancel like</string>
|
||||
<string name="feat_playlist_dialog_mute_title">ban</string>
|
||||
<string name="feat_playlist_dialog_hide_title">hide</string>
|
||||
<string name="feat_playlist_dialog_save_picture_title">save to gallery</string>
|
||||
<string name="feat_playlist_dialog_create_shortcut_title">create shortcut</string>
|
||||
<string name="feat_playlist_error_playlist_not_found">playlist is not existed (%s)</string>
|
||||
|
@ -17,7 +17,7 @@
|
||||
<string name="feat_setting_god_mode_description">adjust layouts by physical volume buttons</string>
|
||||
<string name="feat_setting_experimental_mode">experimental mode</string>
|
||||
<string name="feat_setting_experimental_mode_description">unstable features can throw fatal errors</string>
|
||||
<string name="feat_setting_label_muted_streams">banned streams</string>
|
||||
<string name="feat_setting_label_hidden_streams">hidden streams</string>
|
||||
<string name="feat_setting_label_add_playlist">add playlist</string>
|
||||
<string name="feat_setting_label_parse_from_clipboard">parse clipboard</string>
|
||||
<string name="feat_setting_label_select_from_local_storage">select file</string>
|
||||
|
@ -46,6 +46,7 @@ infix fun PaddingValues.only(side: WindowInsetsSides): PaddingValues {
|
||||
}
|
||||
|
||||
@Composable
|
||||
infix fun PaddingValues.split(side: WindowInsetsSides): Pair<PaddingValues, PaddingValues> {
|
||||
infix fun PaddingValues.split(side: WindowInsetsSides?): Pair<PaddingValues, PaddingValues> {
|
||||
if (side == null) return PaddingValues() to this
|
||||
return (this only side) to (this - (this only side))
|
||||
}
|
@ -16,7 +16,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.ListItem
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.ModalBottomSheetDefaults
|
||||
import androidx.compose.material3.OutlinedCard
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.Text
|
||||
@ -29,9 +28,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import com.m3u.core.util.basic.title
|
||||
import com.m3u.i18n.R.string
|
||||
import com.m3u.material.components.CodeSkeleton
|
||||
import com.m3u.material.model.LocalSpacing
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@ -130,41 +127,6 @@ private fun SortBottomSheetItem(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ConnectBottomSheet(
|
||||
visible: Boolean,
|
||||
connecting: Boolean,
|
||||
code: String,
|
||||
sheetState: SheetState,
|
||||
onCode: (String) -> Unit,
|
||||
onConnect: () -> Unit,
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (visible) {
|
||||
ModalBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismissRequest = {
|
||||
if (!connecting) onDismissRequest()
|
||||
},
|
||||
windowInsets = WindowInsets(0),
|
||||
properties = ModalBottomSheetDefaults.properties(
|
||||
shouldDismissOnBackPress = false
|
||||
)
|
||||
) {
|
||||
CodeSkeleton(
|
||||
title = stringResource(string.feat_foryou_connect_title).title(),
|
||||
subtitle = stringResource(string.feat_foryou_connect_subtitle),
|
||||
code = code,
|
||||
onCode = onCode,
|
||||
modifier = modifier,
|
||||
loading = connecting,
|
||||
onSubmit = onConnect
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BackendConnectBottomSheet(
|
||||
visible: Boolean,
|
||||
|
Reference in New Issue
Block a user