diff --git a/.github/workflows/android-build.yml b/.github/workflows/android-build.yml new file mode 100644 index 0000000..083fc42 --- /dev/null +++ b/.github/workflows/android-build.yml @@ -0,0 +1,36 @@ +name: Android Build + +on: + push: + branches: + - main + pull_request: + branches: + - main + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Build debug APK + run: ./gradlew assembleDebug --stacktrace + + - name: Upload debug APK + uses: actions/upload-artifact@v4 + with: + name: dddplayer-debug-apk + path: app/build/outputs/apk/debug/*.apk diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 086ddb7..ecefc64 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -91,6 +91,7 @@ dependencies { implementation(libs.androidx.constraintlayout) implementation(libs.androidx.fragment.ktx) implementation(libs.okhttp) + implementation(libs.gson) // Media3 dependencies implementation(libs.media3.exoplayer) diff --git a/app/src/main/java/top/rootu/dddplayer/bridge/BridgeConfig.kt b/app/src/main/java/top/rootu/dddplayer/bridge/BridgeConfig.kt new file mode 100644 index 0000000..0aa4069 --- /dev/null +++ b/app/src/main/java/top/rootu/dddplayer/bridge/BridgeConfig.kt @@ -0,0 +1,22 @@ +package top.rootu.dddplayer.bridge + +data class BridgeConfig( + val enabled: Boolean = false, + val sessionId: String? = null, + val mode: BridgeMode = BridgeMode.BROADCAST, + val emitPosition: Boolean = true, + val emitUserActions: Boolean = true, + val positionIntervalMs: Long = 1000L, + val client: String = "lampa", + val eventAction: String = BroadcastTransport.DEFAULT_ACTION_EVENT, + val receiverPackage: String? = null, + val schemaVersion: Int = 1, + val localPort: Int = 39677, + val localToken: String? = null +) + +enum class BridgeMode { + BROADCAST, + LOCAL, + BOTH +} diff --git a/app/src/main/java/top/rootu/dddplayer/bridge/BridgeDispatcher.kt b/app/src/main/java/top/rootu/dddplayer/bridge/BridgeDispatcher.kt new file mode 100644 index 0000000..10ef4cf --- /dev/null +++ b/app/src/main/java/top/rootu/dddplayer/bridge/BridgeDispatcher.kt @@ -0,0 +1,17 @@ +package top.rootu.dddplayer.bridge + +import android.util.Log + +class BridgeDispatcher( + private val config: BridgeConfig, + private val transport: BridgeTransport +) { + fun emit(event: BridgeEvent) { + if (!config.enabled) return + try { + transport.send(event) + } catch (e: Exception) { + Log.w("DDDPlayerBridge", "Failed to send bridge event: ${event::class.simpleName}", e) + } + } +} diff --git a/app/src/main/java/top/rootu/dddplayer/bridge/BridgeEvent.kt b/app/src/main/java/top/rootu/dddplayer/bridge/BridgeEvent.kt new file mode 100644 index 0000000..7aaa84b --- /dev/null +++ b/app/src/main/java/top/rootu/dddplayer/bridge/BridgeEvent.kt @@ -0,0 +1,126 @@ +package top.rootu.dddplayer.bridge + +data class BridgeEnvelope( + val schema: Int = 1, + val type: String, + val client: String, + val sessionId: String?, + val ts: Long, + val payload: BridgeEvent +) + +data class BridgeMediaItem( + val uri: String?, + val title: String?, + val filename: String? = null, + val externalId: String? = null, + val season: Int? = null, + val episode: Int? = null, + val source: String? = null +) + +sealed class BridgeEvent { + abstract val sessionId: String? + abstract val ts: Long + abstract val uri: String? + + data class SessionStarted( + override val sessionId: String?, + override val ts: Long, + override val uri: String?, + val title: String?, + val playlistSize: Int, + val startIndex: Int, + val startPosition: Long? = null, + val currentItem: BridgeMediaItem? = null + ) : BridgeEvent() + + data class PlaybackStateChanged( + override val sessionId: String?, + override val ts: Long, + override val uri: String?, + val isPlaying: Boolean, + val isBuffering: Boolean, + val position: Long?, + val duration: Long?, + val windowIndex: Int? = null, + val title: String? = null + ) : BridgeEvent() + + data class PositionTick( + override val sessionId: String?, + override val ts: Long, + override val uri: String?, + val position: Long?, + val duration: Long?, + val bufferedPosition: Long?, + val bufferedPercentage: Int?, + val windowIndex: Int? = null, + val title: String? = null + ) : BridgeEvent() + + data class SeekCompleted( + override val sessionId: String?, + override val ts: Long, + override val uri: String?, + val fromPosition: Long?, + val toPosition: Long?, + val windowIndex: Int? = null + ) : BridgeEvent() + + data class PlaylistItemChanged( + override val sessionId: String?, + override val ts: Long, + override val uri: String?, + val windowIndex: Int, + val playlistSize: Int, + val title: String?, + val reason: String, + val position: Long?, + val duration: Long?, + val hasPrevious: Boolean, + val hasNext: Boolean, + val currentItem: BridgeMediaItem? = null + ) : BridgeEvent() + + data class PlaybackEnded( + override val sessionId: String?, + override val ts: Long, + override val uri: String?, + val windowIndex: Int, + val playlistSize: Int, + val title: String?, + val position: Long?, + val duration: Long? + ) : BridgeEvent() + + data class SessionFinished( + override val sessionId: String?, + override val ts: Long, + override val uri: String?, + val position: Long?, + val duration: Long?, + val endBy: String, + val windowIndex: Int? = null, + val playlistSize: Int? = null, + val title: String? = null + ) : BridgeEvent() + + data class Error( + override val sessionId: String?, + override val ts: Long, + override val uri: String?, + val code: String?, + val message: String?, + val windowIndex: Int? = null + ) : BridgeEvent() + + data class UserAction( + override val sessionId: String?, + override val ts: Long, + override val uri: String?, + val action: String, + val payload: Map = emptyMap(), + val windowIndex: Int? = null + ) : BridgeEvent() +} diff --git a/app/src/main/java/top/rootu/dddplayer/bridge/BridgeTransport.kt b/app/src/main/java/top/rootu/dddplayer/bridge/BridgeTransport.kt new file mode 100644 index 0000000..e209d43 --- /dev/null +++ b/app/src/main/java/top/rootu/dddplayer/bridge/BridgeTransport.kt @@ -0,0 +1,5 @@ +package top.rootu.dddplayer.bridge + +interface BridgeTransport { + fun send(event: BridgeEvent) +} diff --git a/app/src/main/java/top/rootu/dddplayer/bridge/BroadcastTransport.kt b/app/src/main/java/top/rootu/dddplayer/bridge/BroadcastTransport.kt new file mode 100644 index 0000000..8872bf5 --- /dev/null +++ b/app/src/main/java/top/rootu/dddplayer/bridge/BroadcastTransport.kt @@ -0,0 +1,46 @@ +package top.rootu.dddplayer.bridge + +import android.content.Context +import android.content.Intent +import android.util.Log +import com.google.gson.Gson + +class BroadcastTransport( + private val context: Context, + private val config: BridgeConfig +) : BridgeTransport { + + private val gson = Gson() + + override fun send(event: BridgeEvent) { + val type = event::class.simpleName ?: "Unknown" + val envelope = BridgeEnvelope( + schema = config.schemaVersion, + type = type, + client = config.client, + sessionId = event.sessionId, + ts = event.ts, + payload = event + ) + + val intent = Intent(config.eventAction) + config.receiverPackage?.takeIf { it.isNotBlank() }?.let { intent.setPackage(it) } + intent.putExtra(EXTRA_SCHEMA, config.schemaVersion) + intent.putExtra(EXTRA_CLIENT, config.client) + intent.putExtra(EXTRA_SESSION_ID, event.sessionId) + intent.putExtra(EXTRA_EVENT_TYPE, type) + val json = gson.toJson(envelope) + Log.d("DDDPlayerBridge", json) + intent.putExtra(EXTRA_EVENT_JSON, json) + context.sendBroadcast(intent) + } + + companion object { + const val DEFAULT_ACTION_EVENT = "top.rootu.dddplayer.bridge.EVENT" + const val EXTRA_SCHEMA = "schema" + const val EXTRA_CLIENT = "client" + const val EXTRA_SESSION_ID = "session_id" + const val EXTRA_EVENT_TYPE = "event_type" + const val EXTRA_EVENT_JSON = "event_json" + } +} diff --git a/app/src/main/java/top/rootu/dddplayer/bridge/CompositeTransport.kt b/app/src/main/java/top/rootu/dddplayer/bridge/CompositeTransport.kt new file mode 100644 index 0000000..656f26a --- /dev/null +++ b/app/src/main/java/top/rootu/dddplayer/bridge/CompositeTransport.kt @@ -0,0 +1,21 @@ +package top.rootu.dddplayer.bridge + +import android.util.Log + +class CompositeTransport( + private val transports: List +) : BridgeTransport { + override fun send(event: BridgeEvent) { + transports.forEach { transport -> + try { + transport.send(event) + } catch (e: Exception) { + Log.w( + "DDDPlayerBridge", + "transport failed=${transport::class.simpleName}, event=${event::class.simpleName}", + e + ) + } + } + } +} diff --git a/app/src/main/java/top/rootu/dddplayer/bridge/LocalBridgeServer.kt b/app/src/main/java/top/rootu/dddplayer/bridge/LocalBridgeServer.kt new file mode 100644 index 0000000..7ae2fa3 --- /dev/null +++ b/app/src/main/java/top/rootu/dddplayer/bridge/LocalBridgeServer.kt @@ -0,0 +1,230 @@ +package top.rootu.dddplayer.bridge + +import android.util.Log +import com.google.gson.Gson +import java.io.BufferedReader +import java.io.InputStreamReader +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.URI +import java.net.URLDecoder +import java.nio.charset.StandardCharsets +import java.util.concurrent.Executors +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit + +object LocalBridgeServer { + private const val TAG = "DDDPlayerLocalServer" + + private val gson = Gson() + private val clientExecutor = Executors.newCachedThreadPool() + private val scheduler = Executors.newSingleThreadScheduledExecutor() + + @Volatile private var serverSocket: ServerSocket? = null + @Volatile private var running: Boolean = false + @Volatile private var currentPort: Int = 39677 + @Volatile private var currentToken: String? = null + @Volatile private var stopFuture: ScheduledFuture<*>? = null + + @Synchronized + fun ensureStarted(port: Int = 39677, token: String? = null) { + stopFuture?.cancel(false) + stopFuture = null + + if (running && serverSocket != null && currentPort == port) { + currentToken = token + Log.d(TAG, "already running on 127.0.0.1:$port") + return + } + + stop() + currentPort = port + currentToken = token + + try { + val socket = ServerSocket() + socket.reuseAddress = true + socket.bind(InetSocketAddress(InetAddress.getByName("127.0.0.1"), port)) + serverSocket = socket + running = true + + Thread { acceptLoop(socket) }.apply { + name = "DDDPlayerLocalBridgeServer" + isDaemon = true + start() + } + + Log.d(TAG, "started on http://127.0.0.1:$port") + } catch (e: Exception) { + running = false + serverSocket = null + Log.e(TAG, "failed to start local bridge server", e) + } + } + + @Synchronized + fun scheduleStopAfter(delayMs: Long) { + stopFuture?.cancel(false) + stopFuture = scheduler.schedule({ stop() }, delayMs, TimeUnit.MILLISECONDS) + Log.d(TAG, "scheduled stop after ${delayMs}ms") + } + + @Synchronized + fun stop() { + running = false + try { + serverSocket?.close() + } catch (_: Exception) { + } + serverSocket = null + Log.d(TAG, "stopped") + } + + private fun acceptLoop(socket: ServerSocket) { + while (running && !socket.isClosed) { + try { + val client = socket.accept() + clientExecutor.execute { handleClient(client) } + } catch (e: Exception) { + if (running) Log.w(TAG, "accept failed", e) + } + } + } + + private fun handleClient(socket: Socket) { + socket.use { client -> + try { + val reader = BufferedReader(InputStreamReader(client.getInputStream(), StandardCharsets.UTF_8)) + val requestLine = reader.readLine() + if (requestLine.isNullOrBlank()) { + writeJson(client, 400, "{\"error\":\"empty_request\"}") + return + } + + while (true) { + val line = reader.readLine() ?: break + if (line.isBlank()) break + } + + val parts = requestLine.split(" ") + if (parts.size < 2) { + writeJson(client, 400, "{\"error\":\"bad_request\"}") + return + } + + val method = parts[0].uppercase() + val target = parts[1] + + if (method == "OPTIONS") { + writeJson(client, 204, "") + return + } + if (method != "GET") { + writeJson(client, 405, "{\"error\":\"method_not_allowed\"}") + return + } + + val uri = parseTarget(target) + val path = uri.path ?: "/" + val query = parseQuery(uri.rawQuery) + + when (path) { + "/ping" -> writeJson(client, 200, "{\"ok\":true,\"service\":\"dddplayer-local-bridge\"}") + "/state" -> { + if (!authorized(query)) { + writeJson(client, 403, "{\"error\":\"forbidden\"}") + return + } + val sid = query["sid"] + if (sid.isNullOrBlank()) { + writeJson(client, 400, "{\"error\":\"missing_sid\"}") + return + } + val state = LocalBridgeStore.getState(sid) + if (state == null) { + writeJson(client, 404, "{\"error\":\"not_found\"}") + return + } + writeJson(client, 200, gson.toJson(state)) + } + "/events" -> { + if (!authorized(query)) { + writeJson(client, 403, "{\"error\":\"forbidden\"}") + return + } + val sid = query["sid"] + if (sid.isNullOrBlank()) { + writeJson(client, 400, "{\"error\":\"missing_sid\"}") + return + } + writeJson(client, 200, gson.toJson(LocalBridgeStore.getEvents(sid))) + } + else -> writeJson(client, 404, "{\"error\":\"not_found\"}") + } + } catch (e: Exception) { + Log.w(TAG, "client handling failed", e) + try { writeJson(socket, 500, "{\"error\":\"internal_error\"}") } catch (_: Exception) {} + } + } + } + + private fun authorized(query: Map): Boolean { + val token = currentToken + if (token.isNullOrBlank()) return true + return query["token"] == token + } + + private fun parseTarget(target: String): URI { + return try { + if (target.startsWith("http://") || target.startsWith("https://")) URI(target) + else URI("http://127.0.0.1:$currentPort$target") + } catch (_: Exception) { + URI("http://127.0.0.1:$currentPort/") + } + } + + private fun parseQuery(rawQuery: String?): Map { + if (rawQuery.isNullOrBlank()) return emptyMap() + return rawQuery.split("&").mapNotNull { pair -> + val idx = pair.indexOf("=") + if (idx <= 0) null else { + val key = URLDecoder.decode(pair.substring(0, idx), StandardCharsets.UTF_8.name()) + val value = URLDecoder.decode(pair.substring(idx + 1), StandardCharsets.UTF_8.name()) + key to value + } + }.toMap() + } + + private fun writeJson(socket: Socket, status: Int, body: String) { + val reason = when (status) { + 200 -> "OK" + 204 -> "No Content" + 400 -> "Bad Request" + 403 -> "Forbidden" + 404 -> "Not Found" + 405 -> "Method Not Allowed" + 500 -> "Internal Server Error" + else -> "OK" + } + + val bytes = body.toByteArray(StandardCharsets.UTF_8) + val headers = buildString { + append("HTTP/1.1 $status $reason\r\n") + append("Content-Type: application/json; charset=utf-8\r\n") + append("Access-Control-Allow-Origin: *\r\n") + append("Access-Control-Allow-Methods: GET, OPTIONS\r\n") + append("Access-Control-Allow-Headers: *\r\n") + append("Cache-Control: no-store\r\n") + append("Connection: close\r\n") + append("Content-Length: ${bytes.size}\r\n") + append("\r\n") + } + + val out = socket.getOutputStream() + out.write(headers.toByteArray(StandardCharsets.UTF_8)) + if (bytes.isNotEmpty()) out.write(bytes) + out.flush() + } +} diff --git a/app/src/main/java/top/rootu/dddplayer/bridge/LocalBridgeStore.kt b/app/src/main/java/top/rootu/dddplayer/bridge/LocalBridgeStore.kt new file mode 100644 index 0000000..c202750 --- /dev/null +++ b/app/src/main/java/top/rootu/dddplayer/bridge/LocalBridgeStore.kt @@ -0,0 +1,36 @@ +package top.rootu.dddplayer.bridge + +import java.util.Collections +import java.util.concurrent.ConcurrentHashMap + +object LocalBridgeStore { + private val states = ConcurrentHashMap() + private val events = ConcurrentHashMap>() + + fun put(envelope: BridgeEnvelope) { + val sid = envelope.sessionId ?: return + states[sid] = envelope + val list = events.computeIfAbsent(sid) { Collections.synchronizedList(mutableListOf()) } + synchronized(list) { + list.add(envelope) + while (list.size > 200) list.removeAt(0) + } + } + + fun getState(sessionId: String): BridgeEnvelope? = states[sessionId] + + fun getEvents(sessionId: String): List { + val list = events[sessionId] ?: return emptyList() + return synchronized(list) { list.toList() } + } + + fun clear(sessionId: String) { + states.remove(sessionId) + events.remove(sessionId) + } + + fun clearAll() { + states.clear() + events.clear() + } +} diff --git a/app/src/main/java/top/rootu/dddplayer/bridge/LocalStoreTransport.kt b/app/src/main/java/top/rootu/dddplayer/bridge/LocalStoreTransport.kt new file mode 100644 index 0000000..899e24a --- /dev/null +++ b/app/src/main/java/top/rootu/dddplayer/bridge/LocalStoreTransport.kt @@ -0,0 +1,21 @@ +package top.rootu.dddplayer.bridge + +import android.util.Log + +class LocalStoreTransport( + private val config: BridgeConfig +) : BridgeTransport { + override fun send(event: BridgeEvent) { + val type = event::class.simpleName ?: "Unknown" + val envelope = BridgeEnvelope( + schema = config.schemaVersion, + type = type, + client = config.client, + sessionId = event.sessionId, + ts = event.ts, + payload = event + ) + LocalBridgeStore.put(envelope) + Log.d("DDDPlayerLocalBridge", "stored event=$type session=${event.sessionId}") + } +} diff --git a/app/src/main/java/top/rootu/dddplayer/ui/PlayerActivity.kt b/app/src/main/java/top/rootu/dddplayer/ui/PlayerActivity.kt index 2389b6e..2db3428 100644 --- a/app/src/main/java/top/rootu/dddplayer/ui/PlayerActivity.kt +++ b/app/src/main/java/top/rootu/dddplayer/ui/PlayerActivity.kt @@ -7,6 +7,7 @@ import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.os.Build import android.os.Bundle +import android.util.Log import android.view.KeyEvent import android.view.WindowManager import android.widget.Toast @@ -18,9 +19,20 @@ import androidx.core.net.toUri import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat +import androidx.media3.common.C import androidx.media3.common.util.UnstableApi import top.rootu.dddplayer.BuildConfig import top.rootu.dddplayer.R +import top.rootu.dddplayer.bridge.BridgeConfig +import top.rootu.dddplayer.bridge.BridgeDispatcher +import top.rootu.dddplayer.bridge.BridgeMediaItem +import top.rootu.dddplayer.bridge.BridgeEvent +import top.rootu.dddplayer.bridge.BridgeMode +import top.rootu.dddplayer.bridge.BridgeTransport +import top.rootu.dddplayer.bridge.BroadcastTransport +import top.rootu.dddplayer.bridge.CompositeTransport +import top.rootu.dddplayer.bridge.LocalBridgeServer +import top.rootu.dddplayer.bridge.LocalStoreTransport import top.rootu.dddplayer.utils.IntentUtils import top.rootu.dddplayer.viewmodel.PlayerViewModel @@ -31,6 +43,9 @@ class PlayerActivity : AppCompatActivity() { private val viewModel: PlayerViewModel by viewModels() private var shouldReturnResult = false private var isCompleted = false + private var bridgeConfig = BridgeConfig() + private var bridgeDispatcher: BridgeDispatcher? = null + private var finishReason = "user" // Сохраняем Intent, чтобы обработать его после получения разрешения private var pendingIntent: Intent? = null @@ -88,9 +103,16 @@ class PlayerActivity : AppCompatActivity() { hideSystemUI() viewModel.playbackEnded.observe(this) { ended -> - if (ended && shouldReturnResult) { + if (ended) { isCompleted = true - finish() // Закрываем активити + finishReason = "completion" + if (bridgeConfig.mode == BridgeMode.LOCAL || bridgeConfig.mode == BridgeMode.BOTH) { + LocalBridgeServer.scheduleStopAfter(120_000L) + } + + if (shouldReturnResult) { + finish() // Закрываем активити + } } } @@ -127,6 +149,21 @@ class PlayerActivity : AppCompatActivity() { checkPermissionsAndHandleIntent(intent) } + + private fun createBridgeTransport(config: BridgeConfig): BridgeTransport { + return when (config.mode) { + BridgeMode.BROADCAST -> BroadcastTransport(this, config) + BridgeMode.LOCAL -> { + LocalBridgeServer.ensureStarted(port = config.localPort, token = config.localToken) + LocalStoreTransport(config) + } + BridgeMode.BOTH -> { + LocalBridgeServer.ensureStarted(port = config.localPort, token = config.localToken) + CompositeTransport(listOf(BroadcastTransport(this, config), LocalStoreTransport(config))) + } + } + } + private fun checkPermissionsAndHandleIntent(intent: Intent) { val uri = intent.data @@ -161,11 +198,39 @@ class PlayerActivity : AppCompatActivity() { if (intent == null) return shouldReturnResult = intent.getBooleanExtra("return_result", false) + bridgeConfig = IntentUtils.parseBridgeConfig(intent) + bridgeDispatcher = if (bridgeConfig.enabled) { + val transport = createBridgeTransport(bridgeConfig) + Log.d("DDDPlayerBridge", "Bridge enabled: mode=${bridgeConfig.mode}, session=${bridgeConfig.sessionId}, port=${bridgeConfig.localPort}") + BridgeDispatcher(bridgeConfig, transport) + } else { + Log.d("DDDPlayerBridge", "Bridge disabled") + null + } val (playlist, startIndex) = IntentUtils.parseIntent(this, intent) when { playlist.isNotEmpty() -> { - viewModel.loadPlaylist(playlist, startIndex) + viewModel.setBridgeDispatcher(bridgeDispatcher, bridgeConfig) + val current = playlist.getOrNull(startIndex) + val startPosition = current?.startPositionMs ?: 0L + viewModel.loadPlaylist(playlist, startIndex, startPosition) + bridgeDispatcher?.emit( + BridgeEvent.SessionStarted( + sessionId = bridgeConfig.sessionId, + ts = System.currentTimeMillis(), + uri = current?.uri?.toString(), + title = current?.title, + playlistSize = playlist.size, + startIndex = startIndex, + startPosition = current?.startPositionMs?.takeIf { it > 0 }, + currentItem = BridgeMediaItem( + uri = current?.uri?.toString(), + title = current?.title, + filename = current?.filename + ) + ) + ) } BuildConfig.DEBUG -> { // Дефолтное видео для теста (только в DEBUG) @@ -175,6 +240,7 @@ class PlayerActivity : AppCompatActivity() { listOf(top.rootu.dddplayer.model.MediaItem(defaultUri.toUri())), 0 ) + viewModel.setBridgeDispatcher(bridgeDispatcher, bridgeConfig) } else -> { // В релизе, если нет данных, ничего не делаем. @@ -215,20 +281,41 @@ class PlayerActivity : AppCompatActivity() { override fun finish() { viewModel.saveCurrentSettings() + + val p = viewModel.player + val duration = p?.duration + val position = if (isCompleted) duration else p?.currentPosition + val uri = p?.currentMediaItem?.localConfiguration?.uri?.toString() + + bridgeDispatcher?.emit( + BridgeEvent.SessionFinished( + sessionId = bridgeConfig.sessionId, + ts = System.currentTimeMillis(), + uri = uri, + position = position, + duration = duration?.let { if (it <= 0 || it == C.TIME_UNSET) null else it }, + endBy = finishReason, + windowIndex = p?.currentMediaItemIndex, + playlistSize = p?.mediaItemCount, + title = p?.currentMediaItem?.mediaMetadata?.title?.toString() + ) + ) + + if (bridgeConfig.mode == BridgeMode.LOCAL || bridgeConfig.mode == BridgeMode.BOTH) { + LocalBridgeServer.scheduleStopAfter(120_000L) + } + if (shouldReturnResult) { val resultIntent = Intent("top.rootu.dddplayer.intent.result.VIEW") // Возвращаем URI текущего видео (полезно, если это был плейлист) resultIntent.data = viewModel.player?.currentMediaItem?.localConfiguration?.uri - val duration = viewModel.player?.duration - val position = if (isCompleted) duration else viewModel.player?.currentPosition - resultIntent.putExtra("position", position) resultIntent.putExtra("duration", duration) // Сообщаем, закончилось ли видео само ("completion") или закрыл юзер ("user") - resultIntent.putExtra("end_by", if (isCompleted) "completion" else "user") + resultIntent.putExtra("end_by", finishReason) setResult(RESULT_OK, resultIntent) } diff --git a/app/src/main/java/top/rootu/dddplayer/ui/PlayerFragment.kt b/app/src/main/java/top/rootu/dddplayer/ui/PlayerFragment.kt index 1b5e465..3c1e1be 100644 --- a/app/src/main/java/top/rootu/dddplayer/ui/PlayerFragment.kt +++ b/app/src/main/java/top/rootu/dddplayer/ui/PlayerFragment.kt @@ -91,6 +91,10 @@ class PlayerFragment : Fragment(), OnSurfaceReadyListener { if (doubleTapSeekSeconds != 0) { val current = viewModel.currentPosition.value ?: 0L val target = current + (doubleTapSeekSeconds * 1000L) + viewModel.emitUserAction( + if (doubleTapSeekSeconds > 0) "double_tap_seek_forward" else "double_tap_seek_backward", + mapOf("target_position" to target.coerceAtLeast(0).toString()) + ) viewModel.seekTo(target.coerceAtLeast(0)) doubleTapSeekSeconds = 0 } @@ -397,6 +401,10 @@ class PlayerFragment : Fragment(), OnSurfaceReadyListener { // 1. Завершение перемотки if (swipeAction == 1 && isSwipeSeeking) { + viewModel.emitUserAction( + "swipe_seek_commit", + mapOf("target_position" to swipeSeekCurrentPosition.toString()) + ) viewModel.seekTo(swipeSeekCurrentPosition) ui.hideSeekOverlay() isSwipeSeeking = false @@ -421,9 +429,11 @@ class PlayerFragment : Fragment(), OnSurfaceReadyListener { ui.animatePlaylistSwipeConfirm(isNext) { if (currentSwipeIsExit) { // Логика выхода + viewModel.emitUserAction("swipe_exit") requireActivity().finish() } else { // Переключение трека + viewModel.emitUserAction(if (isNext) "swipe_playlist_next" else "swipe_playlist_prev") if (isNext) viewModel.nextTrack() else viewModel.prevTrack() } } @@ -479,33 +489,53 @@ class PlayerFragment : Fragment(), OnSurfaceReadyListener { ui.controlsView.setOnClickListener { timerController.resetControlsTimer() } // Кнопки плеера - ui.playPauseButton.setOnClickListener { viewModel.togglePlayPause() } - ui.rewindButton.setOnClickListener { viewModel.seekBack() } - ui.ffwdButton.setOnClickListener { viewModel.seekForward() } - ui.prevButton.setOnClickListener { viewModel.prevTrack() } - ui.nextButton.setOnClickListener { viewModel.nextTrack() } + ui.playPauseButton.setOnClickListener { + viewModel.emitUserAction("play_pause_click") + viewModel.togglePlayPause() + } + ui.rewindButton.setOnClickListener { + viewModel.emitUserAction("rewind_click") + viewModel.seekBack() + } + ui.ffwdButton.setOnClickListener { + viewModel.emitUserAction("ffwd_click") + viewModel.seekForward() + } + ui.prevButton.setOnClickListener { + viewModel.emitUserAction("prev_click") + viewModel.prevTrack() + } + ui.nextButton.setOnClickListener { + viewModel.emitUserAction("next_click") + viewModel.nextTrack() + } ui.buttonSpeed.setOnClickListener { + viewModel.emitUserAction("open_speed_menu") ui.hideControls() showPlaybackSpeedMenu() } ui.buttonResize.setOnClickListener { + viewModel.emitUserAction("open_resize_menu") ui.hideControls() showResizeMenu() } ui.buttonAudio.setOnClickListener { + viewModel.emitUserAction("open_audio_menu") ui.hideControls() showAudioTrackMenu() } ui.buttonSubs.setOnClickListener { + viewModel.emitUserAction("open_subtitles_menu") ui.hideControls() showSubtitlesMenu() } ui.buttonSettings.setOnClickListener { + viewModel.emitUserAction("open_main_menu") ui.hideControls() showMainMenu() } @@ -520,8 +550,14 @@ class PlayerFragment : Fragment(), OnSurfaceReadyListener { true } - ui.buttonPlaylist.setOnClickListener { showPlaylist() } - ui.buttonQuality.setOnClickListener { showQualityPopup() } + ui.buttonPlaylist.setOnClickListener { + viewModel.emitUserAction("open_playlist_dialog") + showPlaylist() + } + ui.buttonQuality.setOnClickListener { + viewModel.emitUserAction("open_quality_menu") + showQualityPopup() + } // SeekBar ui.seekBar.setOnSeekBarChangeListener(object : android.widget.SeekBar.OnSeekBarChangeListener { @@ -541,7 +577,13 @@ class PlayerFragment : Fragment(), OnSurfaceReadyListener { timerController.stopControlsTimer() } override fun onStopTrackingTouch(seekBar: android.widget.SeekBar?) { - seekBar?.progress?.let { viewModel.seekTo(it.toLong()) } + seekBar?.progress?.let { + viewModel.emitUserAction( + "seekbar_release", + mapOf("target_position" to it.toLong().toString()) + ) + viewModel.seekTo(it.toLong()) + } viewModel.isUserInteracting = false timerController.resetControlsTimer() } diff --git a/app/src/main/java/top/rootu/dddplayer/utils/IntentUtils.kt b/app/src/main/java/top/rootu/dddplayer/utils/IntentUtils.kt index 2c944c5..f2037a8 100644 --- a/app/src/main/java/top/rootu/dddplayer/utils/IntentUtils.kt +++ b/app/src/main/java/top/rootu/dddplayer/utils/IntentUtils.kt @@ -9,11 +9,34 @@ import android.os.Bundle import android.os.Parcelable import android.provider.OpenableColumns import androidx.core.net.toUri +import top.rootu.dddplayer.bridge.BridgeConfig +import top.rootu.dddplayer.bridge.BridgeMode +import top.rootu.dddplayer.bridge.BroadcastTransport import top.rootu.dddplayer.model.MediaItem import top.rootu.dddplayer.model.SubtitleItem object IntentUtils { + private fun parseFragmentParams(uri: Uri?): Map { + val fragment = uri?.encodedFragment ?: return emptyMap() + if (fragment.isBlank()) return emptyMap() + + return fragment.split("&") + .mapNotNull { pair -> + val idx = pair.indexOf("=") + if (idx <= 0) return@mapNotNull null + val key = Uri.decode(pair.substring(0, idx)) + val value = Uri.decode(pair.substring(idx + 1)) + if (key.isBlank()) null else key to value + } + .toMap() + } + + private fun stripFragment(uri: Uri): Uri { + return uri.buildUpon().fragment(null).build() + } + + /** * Парсит Intent и возвращает список медиа-элементов и стартовую позицию. * Требует Context для разрешения имен файлов из content:// URI. @@ -39,8 +62,46 @@ object IntentUtils { return Pair(emptyList(), 0) } - private fun parseSingleFile(context: Context, intent: Intent): Pair, Int> { - val uri = intent.data ?: return Pair(emptyList(), 0) + fun parseBridgeConfig(intent: Intent): BridgeConfig { + val fragment = parseFragmentParams(intent.data) + + val enabledFromFragment = + fragment.containsKey("ddd_mode") || + fragment.containsKey("ddd_sid") || + fragment.containsKey("ddd_port") || + fragment.containsKey("ddd_token") + + val modeString = + intent.getStringExtra("bridge_mode") + ?: fragment["ddd_mode"] + ?: "broadcast" + + val mode = when (modeString.lowercase()) { + "local" -> BridgeMode.LOCAL + "both" -> BridgeMode.BOTH + "broadcast" -> BridgeMode.BROADCAST + else -> BridgeMode.BROADCAST + } + + return BridgeConfig( + enabled = intent.getBooleanExtra("bridge_enabled", false) || enabledFromFragment, + sessionId = intent.getStringExtra("bridge_session_id") ?: fragment["ddd_sid"], + mode = mode, + emitPosition = intent.getBooleanExtra("bridge_emit_position", true), + emitUserActions = intent.getBooleanExtra("bridge_emit_user_actions", true), + positionIntervalMs = intent.getLongExtra("bridge_position_interval_ms", 1000L).coerceAtLeast(250L), + client = intent.getStringExtra("bridge_client") ?: fragment["ddd_client"] ?: "lampa", + eventAction = intent.getStringExtra("bridge_event_action") ?: BroadcastTransport.DEFAULT_ACTION_EVENT, + receiverPackage = intent.getStringExtra("bridge_receiver_package"), + schemaVersion = intent.getIntExtra("bridge_schema_version", 1), + localPort = fragment["ddd_port"]?.toIntOrNull() ?: 39677, + localToken = intent.getStringExtra("bridge_local_token") ?: fragment["ddd_token"] + ) + } + + private fun parseSingleFile(context: Context, intent: Intent): Pair, Int> { + val rawUri = intent.data ?: return Pair(emptyList(), 0) + val uri = stripFragment(rawUri) val extras = intent.extras ?: Bundle.EMPTY // Пытаемся найти заголовок в Extras (некоторые приложения передают его) @@ -53,7 +114,7 @@ object IntentUtils { title = filename ?: uri.lastPathSegment ?: "Video" } - val startPosition = extras.getInt("position", 0).toLong() + val startPosition = getLongExtraCompat(extras, "position", 0L) // Single poster val singlePoster = extras.getString("thumbnail") // Single Video Subtitles @@ -64,7 +125,7 @@ object IntentUtils { title = title, filename = filename, posterUri = singlePoster?.toUri(), - headers = emptyMap(), + headers = parseHeaders(extras), subtitles = singleSubs, startPositionMs = startPosition ) @@ -82,17 +143,10 @@ object IntentUtils { val posters = getSmartStringArray(extras, "video_list.thumbnail") val playlistSubsBundles = getParcelableArrayListCompat(extras, "video_list.subtitles") - // Headers - val headersMap = mutableMapOf() - val headersArray = getSmartStringArray(extras, "headers") - if (headersArray != null) { - for (i in 0 until headersArray.size - 1 step 2) { - headersMap[headersArray[i]] = headersArray[i + 1] - } - } + val headersMap = parseHeaders(extras) val playlist = mutableListOf() - var startIndex = 0 + var startIndex = extras.getInt("start_index", 0) for (i in videoListUris.indices) { val uri = (videoListUris[i] as? Uri) ?: (videoListUris[i] as? String)?.toUri() ?: continue @@ -108,12 +162,12 @@ object IntentUtils { } // Если dataUri совпадает с текущим элементом списка, берем позицию из extras - val pos = if (dataUri != null && uri == dataUri) extras.getInt("position", 0).toLong() else 0L + val pos = if (dataUri != null && uri == dataUri) getLongExtraCompat(extras, "position", 0L) else 0L if (dataUri != null && uri == dataUri) startIndex = i playlist.add( MediaItem( - uri = uri, + uri = stripFragment(uri), title = title, filename = filenames?.getOrNull(i), posterUri = posters?.getOrNull(i)?.takeIf { it.isNotEmpty() }?.toUri(), @@ -123,9 +177,31 @@ object IntentUtils { ) ) } + startIndex = startIndex.coerceIn(0, (playlist.size - 1).coerceAtLeast(0)) return Pair(playlist, startIndex) } + + private fun parseHeaders(extras: Bundle): Map { + val headersArray = getSmartStringArray(extras, "headers") ?: return emptyMap() + val result = mutableMapOf() + for (i in 0 until headersArray.size - 1 step 2) { + val key = headersArray[i] + val value = headersArray[i + 1] + if (key.isNotBlank()) result[key] = value + } + return result + } + + private fun getLongExtraCompat(bundle: Bundle, key: String, defaultValue: Long = 0L): Long { + return when (val value = bundle.get(key)) { + is Long -> value + is Int -> value.toLong() + is String -> value.toLongOrNull() ?: defaultValue + else -> defaultValue + } + } + /** * Извлекает реальное имя файла из content:// URI. */ @@ -217,4 +293,4 @@ object IntentUtils { bundle.getParcelableArrayList(key) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/top/rootu/dddplayer/viewmodel/PlayerViewModel.kt b/app/src/main/java/top/rootu/dddplayer/viewmodel/PlayerViewModel.kt index 3013e51..f5d69ae 100644 --- a/app/src/main/java/top/rootu/dddplayer/viewmodel/PlayerViewModel.kt +++ b/app/src/main/java/top/rootu/dddplayer/viewmodel/PlayerViewModel.kt @@ -26,6 +26,9 @@ import androidx.media3.exoplayer.hls.HlsManifest import androidx.media3.exoplayer.hls.playlist.HlsPlaylistTracker import kotlinx.coroutines.launch import top.rootu.dddplayer.R +import top.rootu.dddplayer.bridge.BridgeConfig +import top.rootu.dddplayer.bridge.BridgeDispatcher +import top.rootu.dddplayer.bridge.BridgeEvent import top.rootu.dddplayer.data.SettingsRepository import top.rootu.dddplayer.data.VideoSettings import top.rootu.dddplayer.logic.AnaglyphLogic @@ -81,6 +84,10 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) private val repository = SettingsRepository.getInstance(application) + private var bridgeConfig: BridgeConfig = BridgeConfig() + private var bridgeDispatcher: BridgeDispatcher? = null + private var lastBridgeTickAt = 0L + // Делегат для 3D/VR настроек val anaglyphDelegate = AnaglyphDelegate(repository) @@ -95,6 +102,11 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) // Детектор FPS private val fpsDetector = RuntimeFpsDetector() + fun setBridgeDispatcher(dispatcher: BridgeDispatcher?, config: BridgeConfig) { + bridgeDispatcher = dispatcher + bridgeConfig = config + } + private val _currentPosition = MutableLiveData() val currentPosition: LiveData = _currentPosition private val _duration = MutableLiveData() @@ -247,6 +259,24 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } } } + + val now = System.currentTimeMillis() + if (bridgeConfig.enabled && bridgeConfig.emitPosition && now - lastBridgeTickAt >= bridgeConfig.positionIntervalMs) { + bridgeDispatcher?.emit( + BridgeEvent.PositionTick( + sessionId = bridgeConfig.sessionId, + ts = now, + uri = p.currentMediaItem?.localConfiguration?.uri?.toString(), + position = p.currentPosition, + duration = normalizeDuration(p.duration), + bufferedPosition = p.bufferedPosition, + bufferedPercentage = _bufferedPercentage.value, + windowIndex = p.currentMediaItemIndex, + title = p.currentMediaItem?.mediaMetadata?.title?.toString() + ) + ) + lastBridgeTickAt = now + } handler.postDelayed(this, 200) } } @@ -255,6 +285,17 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) override fun onIsPlayingChanged(isPlaying: Boolean) { _isPlaying.value = isPlaying updateProgressUpdaterState() + bridgeDispatcher?.emit( + BridgeEvent.PlaybackStateChanged( + sessionId = bridgeConfig.sessionId, + ts = System.currentTimeMillis(), + uri = player?.currentMediaItem?.localConfiguration?.uri?.toString(), + isPlaying = isPlaying, + isBuffering = _isBuffering.value == true, + position = player?.currentPosition, + duration = player?.duration + ) + ) } override fun onPlaybackStateChanged(playbackState: Int) { @@ -265,8 +306,34 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) // После буферизации или старта обновляем инфо player?.videoFormat?.let { updateVideoInfoBadge(it) } } - if (playbackState == Player.STATE_ENDED) _playbackEnded.value = true + if (playbackState == Player.STATE_ENDED) { + _playbackEnded.value = true + val p = player + bridgeDispatcher?.emit( + BridgeEvent.PlaybackEnded( + sessionId = bridgeConfig.sessionId, + ts = System.currentTimeMillis(), + uri = p?.currentMediaItem?.localConfiguration?.uri?.toString(), + windowIndex = p?.currentMediaItemIndex ?: 0, + playlistSize = p?.mediaItemCount ?: 0, + title = p?.currentMediaItem?.mediaMetadata?.title?.toString(), + position = p?.currentPosition, + duration = p?.duration?.let { normalizeDuration(it) } + ) + ) + } updateProgressUpdaterState() + bridgeDispatcher?.emit( + BridgeEvent.PlaybackStateChanged( + sessionId = bridgeConfig.sessionId, + ts = System.currentTimeMillis(), + uri = player?.currentMediaItem?.localConfiguration?.uri?.toString(), + isPlaying = player?.isPlaying == true, + isBuffering = _isBuffering.value == true, + position = player?.currentPosition, + duration = player?.duration + ) + ) _isLive.value = player?.isCurrentMediaItemLive ?: false } @@ -276,11 +343,36 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } _fatalError.postValue(error) _isPlaying.postValue(false) + bridgeDispatcher?.emit( + BridgeEvent.Error( + sessionId = bridgeConfig.sessionId, + ts = System.currentTimeMillis(), + uri = player?.currentMediaItem?.localConfiguration?.uri?.toString(), + code = error.errorCodeName, + message = error.message + ) + ) } override fun onMediaItemTransition(mediaItem: Media3MediaItem?, reason: Int) { ioRetryCount = 0 // Сброс счетчика при смене видео handleMediaItemTransition(mediaItem) + val p = player + bridgeDispatcher?.emit( + BridgeEvent.PlaylistItemChanged( + sessionId = bridgeConfig.sessionId, + ts = System.currentTimeMillis(), + uri = mediaItem?.localConfiguration?.uri?.toString(), + windowIndex = p?.currentMediaItemIndex ?: 0, + playlistSize = p?.mediaItemCount ?: 0, + title = mediaItem?.mediaMetadata?.title?.toString(), + reason = bridgeTransitionReason(reason), + position = p?.currentPosition, + duration = p?.duration?.let { normalizeDuration(it) }, + hasPrevious = p?.hasPreviousMediaItem() == true, + hasNext = p?.hasNextMediaItem() == true + ) + ) } override fun onVideoSizeChanged(videoSize: VideoSize) { @@ -303,6 +395,15 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) if (reason == Player.DISCONTINUITY_REASON_SEEK) { // При перемотке просто останавливаем текущий процесс детекции, если он был запущен. fpsDetector.stop(player) + bridgeDispatcher?.emit( + BridgeEvent.SeekCompleted( + sessionId = bridgeConfig.sessionId, + ts = System.currentTimeMillis(), + uri = player?.currentMediaItem?.localConfiguration?.uri?.toString(), + fromPosition = oldPosition.positionMs, + toPosition = newPosition.positionMs + ) + ) } } } @@ -823,9 +924,9 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) } } - fun loadPlaylist(items: List, startIndex: Int) { + fun loadPlaylist(items: List, startIndex: Int, startPosMs: Long? = null) { _currentPlaylist.value = items - val startPos = items.getOrNull(startIndex)?.startPositionMs ?: 0L + val startPos = startPosMs ?: items.getOrNull(startIndex)?.startPositionMs ?: 0L playerManager.loadPlaylist(items, startIndex, startPos) viewModelScope.launch { repository.cleanupOldSettings() } } @@ -1393,9 +1494,38 @@ class PlayerViewModel(application: Application) : AndroidViewModel(application) _toastMessage.value = null } + + private fun normalizeDuration(value: Long): Long? { + return if (value <= 0 || value == C.TIME_UNSET) null else value + } + + private fun bridgeTransitionReason(reason: Int): String { + return when (reason) { + Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto" + Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek" + Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "playlist_changed" + Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> "repeat" + else -> "unknown" + } + } + + fun emitUserAction(action: String, payload: Map = emptyMap()) { + if (!bridgeConfig.enabled || !bridgeConfig.emitUserActions) return + + bridgeDispatcher?.emit( + BridgeEvent.UserAction( + sessionId = bridgeConfig.sessionId, + ts = System.currentTimeMillis(), + uri = player?.currentMediaItem?.localConfiguration?.uri?.toString(), + action = action, + payload = payload + ) + ) + } + override fun onCleared() { super.onCleared() handler.removeCallbacks(progressUpdater) playerManager.releasePlayer(isFinalRelease = true, saveState = false) } -} \ No newline at end of file +} diff --git a/dddplayer2_lampa_patch.diff b/dddplayer2_lampa_patch.diff new file mode 100644 index 0000000..c7d006a --- /dev/null +++ b/dddplayer2_lampa_patch.diff @@ -0,0 +1,678 @@ +*** Begin Patch +*** Add File: app/src/main/java/top/rootu/dddplayer/bridge/BridgeConfig.kt ++package top.rootu.dddplayer.bridge ++ ++/** ++ * Configuration for the bridge between DDDPlayer and external clients such as ++ * Lampa/Lampac. ++ * ++ * @param enabled whether the bridge is enabled ++ * @param sessionId optional identifier for the current session ++ * @param mode the transport mode used by the bridge ++ * @param callbackUrl optional callback URL for HTTP transport ++ * @param token optional auth token for HTTP requests ++ * @param emitPosition whether position ticks should be emitted periodically ++ * @param emitUserActions whether user action events should be emitted ++ * @param positionIntervalMs interval in milliseconds between position ticks ++ * @param client name of the client using the bridge (e.g. "lampa", "lampac") ++ */ ++data class BridgeConfig( ++ val enabled: Boolean = false, ++ val sessionId: String? = null, ++ val mode: BridgeMode = BridgeMode.BROADCAST, ++ val callbackUrl: String? = null, ++ val token: String? = null, ++ val emitPosition: Boolean = true, ++ val emitUserActions: Boolean = true, ++ val positionIntervalMs: Long = 1000L, ++ val client: String = "custom" ++) ++ ++/** ++ * Transport mode for the bridge. ++ */ ++enum class BridgeMode { ++ BROADCAST, ++ HTTP, ++ WEBSOCKET ++} +*** End Patch +*** Begin Patch +*** Add File: app/src/main/java/top/rootu/dddplayer/bridge/BridgeEvent.kt ++package top.rootu.dddplayer.bridge ++ ++/** ++ * Base sealed class representing events emitted by the bridge. ++ * ++ * All events carry the session id, timestamp and current media uri. ++ */ ++sealed class BridgeEvent { ++ abstract val sessionId: String? ++ abstract val ts: Long ++ abstract val uri: String? ++ ++ data class SessionStarted( ++ override val sessionId: String?, ++ override val ts: Long, ++ override val uri: String?, ++ val title: String?, ++ val playlistSize: Int, ++ val startIndex: Int ++ ) : BridgeEvent() ++ ++ data class SessionFinished( ++ override val sessionId: String?, ++ override val ts: Long, ++ override val uri: String?, ++ val position: Long?, ++ val duration: Long?, ++ val endBy: String ++ ) : BridgeEvent() ++ ++ data class PlaybackStateChanged( ++ override val sessionId: String?, ++ override val ts: Long, ++ override val uri: String?, ++ val isPlaying: Boolean, ++ val isBuffering: Boolean, ++ val position: Long?, ++ val duration: Long? ++ ) : BridgeEvent() ++ ++ data class PositionTick( ++ override val sessionId: String?, ++ override val ts: Long, ++ override val uri: String?, ++ val position: Long?, ++ val duration: Long?, ++ val bufferedPosition: Long?, ++ val bufferedPercentage: Int? ++ ) : BridgeEvent() ++ ++ data class SeekCompleted( ++ override val sessionId: String?, ++ override val ts: Long, ++ override val uri: String?, ++ val fromPosition: Long?, ++ val toPosition: Long? ++ ) : BridgeEvent() ++ ++ data class PlaylistItemChanged( ++ override val sessionId: String?, ++ override val ts: Long, ++ override val uri: String?, ++ val windowIndex: Int, ++ val title: String? ++ ) : BridgeEvent() ++ ++ data class UserAction( ++ override val sessionId: String?, ++ override val ts: Long, ++ override val uri: String?, ++ val action: String, ++ val payload: Map = emptyMap() ++ ) : BridgeEvent() ++ ++ data class Error( ++ override val sessionId: String?, ++ override val ts: Long, ++ override val uri: String?, ++ val code: String?, ++ val message: String? ++ ) : BridgeEvent() ++} +*** End Patch +*** Begin Patch +*** Add File: app/src/main/java/top/rootu/dddplayer/bridge/BridgeTransport.kt ++package top.rootu.dddplayer.bridge ++ ++/** ++ * Interface for bridge transports. ++ * ++ * A transport is responsible for delivering bridge events to an external client. ++ */ ++interface BridgeTransport { ++ fun send(event: BridgeEvent) ++} +*** End Patch +*** Begin Patch +*** Add File: app/src/main/java/top/rootu/dddplayer/bridge/BridgeDispatcher.kt ++package top.rootu.dddplayer.bridge ++ ++/** ++ * Dispatcher used by the player to emit bridge events to the configured transport. ++ * ++ * The dispatcher simply forwards events to the underlying transport if the bridge is enabled. ++ */ ++class BridgeDispatcher( ++ private val config: BridgeConfig, ++ private val transport: BridgeTransport ++) { ++ fun emit(event: BridgeEvent) { ++ if (!config.enabled) return ++ transport.send(event) ++ } ++} +*** End Patch +*** Begin Patch +*** Add File: app/src/main/java/top/rootu/dddplayer/bridge/BroadcastTransport.kt ++package top.rootu.dddplayer.bridge ++ ++import android.content.Context ++import android.content.Intent ++import com.google.gson.Gson ++ ++/** ++ * Transport implementation that sends bridge events via Android broadcasts. ++ * ++ * This transport is intended for on-device integrations such as Lampa. ++ * The broadcast action name is fixed; clients should register a BroadcastReceiver ++ * for this action to receive events. ++ */ ++class BroadcastTransport( ++ private val context: Context, ++ private val packageName: String? = null ++) : BridgeTransport { ++ ++ private val gson = Gson() ++ ++ override fun send(event: BridgeEvent) { ++ val intent = Intent(ACTION_EVENT) ++ packageName?.let { intent.setPackage(it) } ++ intent.putExtra(EXTRA_EVENT_JSON, gson.toJson(event)) ++ intent.putExtra(EXTRA_EVENT_TYPE, event::class.simpleName) ++ context.sendBroadcast(intent) ++ } ++ ++ companion object { ++ /** ++ * Action name used for all bridge event broadcasts. ++ */ ++ const val ACTION_EVENT = "top.rootu.dddplayer.bridge.EVENT" ++ /** ++ * Extra name carrying the JSON-encoded event. ++ */ ++ const val EXTRA_EVENT_JSON = "event_json" ++ /** ++ * Extra name carrying the simple class name of the event. ++ */ ++ const val EXTRA_EVENT_TYPE = "event_type" ++ } ++} +*** End Patch +*** Begin Patch +*** Update File: app/src/main/java/top/rootu/dddplayer/utils/IntentUtils.kt +@@ +-import android.content.Context +-import android.content.Intent +-import android.net.Uri ++import android.content.Context ++import android.content.Intent ++import android.net.Uri ++import top.rootu.dddplayer.bridge.BridgeConfig ++import top.rootu.dddplayer.bridge.BridgeMode +@@ object IntentUtils { + fun parseIntent(context: Context, intent: Intent): Pair, Int> { +@@ + return Pair(playlist, startIndex) + } ++ ++ /** ++ * Parse bridge configuration extras from the given intent. ++ * ++ * Returns a BridgeConfig describing how the player should emit events to an external client. ++ * If no bridge extras are set the returned config will have enabled = false. ++ */ ++ fun parseBridgeConfig(intent: Intent): BridgeConfig { ++ val enabled = intent.getBooleanExtra("bridge_enabled", false) ++ val sessionId = intent.getStringExtra("bridge_session_id") ++ val modeString = intent.getStringExtra("bridge_mode")?.lowercase() ++ val mode = when (modeString) { ++ "http" -> BridgeMode.HTTP ++ "websocket" -> BridgeMode.WEBSOCKET ++ else -> BridgeMode.BROADCAST ++ } ++ val callbackUrl = intent.getStringExtra("bridge_callback_url") ++ val token = intent.getStringExtra("bridge_token") ++ val emitPosition = intent.getBooleanExtra("bridge_emit_position", true) ++ val emitUserActions = intent.getBooleanExtra("bridge_emit_user_actions", true) ++ val intervalMs = intent.getLongExtra("bridge_position_interval_ms", 1000L) ++ val client = intent.getStringExtra("bridge_client") ?: "custom" ++ ++ return BridgeConfig( ++ enabled = enabled, ++ sessionId = sessionId, ++ mode = mode, ++ callbackUrl = callbackUrl, ++ token = token, ++ emitPosition = emitPosition, ++ emitUserActions = emitUserActions, ++ positionIntervalMs = intervalMs, ++ client = client ++ ) ++ } +*** End Patch +*** Begin Patch +*** Update File: app/src/main/java/top/rootu/dddplayer/ui/PlayerActivity.kt +@@ + import android.app.PendingIntent +@@ + import top.rootu.dddplayer.utils.IntentUtils ++import top.rootu.dddplayer.bridge.BridgeConfig ++import top.rootu.dddplayer.bridge.BridgeDispatcher ++import top.rootu.dddplayer.bridge.BridgeEvent ++import top.rootu.dddplayer.bridge.BridgeMode ++import top.rootu.dddplayer.bridge.BroadcastTransport +@@ + private var shouldReturnResult: Boolean = false + private var isCompleted: Boolean = false + private var pendingIntent: PendingIntent? = null ++ // Bridge related fields ++ private var bridgeConfig: BridgeConfig = BridgeConfig() ++ private var bridgeDispatcher: BridgeDispatcher? = null ++ private var finishReason: String = "user" +@@ + private fun handleIntent(intent: Intent?) { + if (intent == null) return +@@ +- shouldReturnResult = intent.getBooleanExtra("return_result", false) ++ shouldReturnResult = intent.getBooleanExtra("return_result", false) ++ // Parse bridge configuration ++ bridgeConfig = IntentUtils.parseBridgeConfig(intent) ++ if (bridgeConfig.enabled) { ++ bridgeDispatcher = when (bridgeConfig.mode) { ++ BridgeMode.BROADCAST -> { ++ BridgeDispatcher( ++ bridgeConfig, ++ BroadcastTransport(this) ++ ) ++ } ++ else -> { ++ // Default to broadcast for unsupported modes ++ BridgeDispatcher( ++ bridgeConfig, ++ BroadcastTransport(this) ++ ) ++ } ++ } ++ } +@@ +- playlist.isNotEmpty() -> { +- viewModel.loadPlaylist(playlist, startIndex) ++ playlist.isNotEmpty() -> { ++ viewModel.loadPlaylist(playlist, startIndex) ++ // Pass dispatcher to view model ++ viewModel.setBridgeDispatcher(bridgeDispatcher, bridgeConfig) ++ // Emit session started event ++ bridgeDispatcher?.emit( ++ BridgeEvent.SessionStarted( ++ sessionId = bridgeConfig.sessionId, ++ ts = System.currentTimeMillis(), ++ uri = playlist.getOrNull(startIndex)?.uri?.toString(), ++ title = playlist.getOrNull(startIndex)?.mediaMetadata?.title?.toString(), ++ playlistSize = playlist.size, ++ startIndex = startIndex ++ ) ++ ) +*** End Patch +*** Begin Patch +*** Update File: app/src/main/java/top/rootu/dddplayer/ui/PlayerActivity.kt +@@ + override fun finish() { +- viewModel.saveCurrentSettings() +- +- val resultIntent = Intent(ACTION_RESULT) +- if (shouldReturnResult) { +- val position = if (isCompleted) viewModel.duration.value else viewModel.currentPosition.value +- resultIntent.data = viewModel.currentMediaItem.value?.playableUri +- resultIntent.putExtra("position", position) +- resultIntent.putExtra("duration", viewModel.duration.value) +- resultIntent.putExtra("end_by", if (isCompleted) "completion" else "user") +- setResult(Activity.RESULT_OK, resultIntent) +- } +- super.finish() ++ viewModel.saveCurrentSettings() ++ // Emit session finished event ++ val currentUri = viewModel.currentMediaItem.value?.playableUri ++ val duration = viewModel.duration.value ++ val position = if (finishReason == "completion") duration else viewModel.currentPosition.value ++ bridgeDispatcher?.emit( ++ BridgeEvent.SessionFinished( ++ sessionId = bridgeConfig.sessionId, ++ ts = System.currentTimeMillis(), ++ uri = currentUri?.toString(), ++ position = position, ++ duration = duration, ++ endBy = finishReason ++ ) ++ ) ++ if (shouldReturnResult) { ++ val resultIntent = Intent(ACTION_RESULT) ++ resultIntent.data = currentUri ++ resultIntent.putExtra("position", position) ++ resultIntent.putExtra("duration", duration) ++ resultIntent.putExtra("end_by", finishReason) ++ setResult(Activity.RESULT_OK, resultIntent) ++ } ++ super.finish() + } +*** End Patch +*** Begin Patch +*** Update File: app/src/main/java/top/rootu/dddplayer/viewmodel/PlayerViewModel.kt +@@ +-import android.net.Uri ++import android.net.Uri ++import top.rootu.dddplayer.bridge.BridgeConfig ++import top.rootu.dddplayer.bridge.BridgeDispatcher ++import top.rootu.dddplayer.bridge.BridgeEvent +@@ + class PlayerViewModel( +@@ + ) : ViewModel(), Player.Listener { +@@ +- // Existing declarations... ++ // Bridge dispatcher and config ++ private var bridgeConfig: BridgeConfig = BridgeConfig() ++ private var bridgeDispatcher: BridgeDispatcher? = null ++ private var lastTickSentAt: Long = 0L ++ ++ /** ++ * Attach the given dispatcher and config to this ViewModel. ++ * ++ * This should be called from PlayerActivity once the bridge has been configured. ++ */ ++ fun setBridgeDispatcher(dispatcher: BridgeDispatcher?, config: BridgeConfig) { ++ bridgeDispatcher = dispatcher ++ bridgeConfig = config ++ } +@@ + private val progressUpdater = object : Runnable { +@@ + override fun run() { + val currentPlayer = player ?: return +@@ + _bufferedPosition.value = currentPlayer.bufferedPosition +- +- handler.postDelayed(this, PROGRESS_UPDATE_INTERVAL_MS) ++ // Emit position ticks via bridge ++ val now = System.currentTimeMillis() ++ if (bridgeConfig.enabled && bridgeConfig.emitPosition) { ++ if (now - lastTickSentAt >= bridgeConfig.positionIntervalMs) { ++ bridgeDispatcher?.emit( ++ BridgeEvent.PositionTick( ++ sessionId = bridgeConfig.sessionId, ++ ts = now, ++ uri = currentPlayer.currentMediaItem?.localConfiguration?.uri?.toString(), ++ position = currentPlayer.currentPosition, ++ duration = currentPlayer.duration, ++ bufferedPosition = currentPlayer.bufferedPosition, ++ bufferedPercentage = _bufferedPercentage.value ++ ) ++ ) ++ lastTickSentAt = now ++ } ++ } ++ handler.postDelayed(this, PROGRESS_UPDATE_INTERVAL_MS) + } + } +@@ + override fun onIsPlayingChanged(isPlaying: Boolean) { +@@ + updateProgressUpdaterState() ++ // Emit playback state change ++ val p = player ++ bridgeDispatcher?.emit( ++ BridgeEvent.PlaybackStateChanged( ++ sessionId = bridgeConfig.sessionId, ++ ts = System.currentTimeMillis(), ++ uri = p?.currentMediaItem?.localConfiguration?.uri?.toString(), ++ isPlaying = isPlaying, ++ isBuffering = _isBuffering.value == true, ++ position = p?.currentPosition, ++ duration = p?.duration ++ ) ++ ) + } +@@ + override fun onPlaybackStateChanged(playbackState: Int) { +@@ + _isBuffering.value = playbackState == Player.STATE_BUFFERING +@@ +- // Existing code may update UI etc. ++ // Emit state change after updating ++ val p = player ++ bridgeDispatcher?.emit( ++ BridgeEvent.PlaybackStateChanged( ++ sessionId = bridgeConfig.sessionId, ++ ts = System.currentTimeMillis(), ++ uri = p?.currentMediaItem?.localConfiguration?.uri?.toString(), ++ isPlaying = p?.isPlaying == true, ++ isBuffering = _isBuffering.value == true, ++ position = p?.currentPosition, ++ duration = p?.duration ++ ) ++ ) + } +@@ + override fun onPlayerError(error: PlaybackException) { +@@ + // Post value to LiveData + _fatalError.postValue(error) + _isPlaying.postValue(false) ++ // Emit error event ++ bridgeDispatcher?.emit( ++ BridgeEvent.Error( ++ sessionId = bridgeConfig.sessionId, ++ ts = System.currentTimeMillis(), ++ uri = player?.currentMediaItem?.localConfiguration?.uri?.toString(), ++ code = error.errorCodeName, ++ message = error.message ++ ) ++ ) + } +@@ + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { +@@ + updateTrackState() ++ // Emit playlist item changed ++ bridgeDispatcher?.emit( ++ BridgeEvent.PlaylistItemChanged( ++ sessionId = bridgeConfig.sessionId, ++ ts = System.currentTimeMillis(), ++ uri = mediaItem?.localConfiguration?.uri?.toString(), ++ windowIndex = player?.currentMediaItemIndex ?: 0, ++ title = mediaItem?.mediaMetadata?.title?.toString() ++ ) ++ ) + } +@@ + override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, reason: Int) { +@@ + if (reason == DISCONTINUITY_REASON_SEEK) { + val difference = newPosition.positionMs - oldPosition.positionMs + if (abs(difference) < 200) return + } +@@ ++ // If a seek occurred emit a SeekCompleted event ++ if (reason == DISCONTINUITY_REASON_SEEK) { ++ bridgeDispatcher?.emit( ++ BridgeEvent.SeekCompleted( ++ sessionId = bridgeConfig.sessionId, ++ ts = System.currentTimeMillis(), ++ uri = player?.currentMediaItem?.localConfiguration?.uri?.toString(), ++ fromPosition = oldPosition.positionMs, ++ toPosition = newPosition.positionMs ++ ) ++ ) ++ } + } +@@ + // add at bottom ++ /** ++ * Emit a user action through the bridge if enabled. ++ * ++ * @param action the action string to emit (e.g. "play_pause_click") ++ * @param payload optional additional information about the action ++ */ ++ fun emitUserAction(action: String, payload: Map = emptyMap()) { ++ if (!bridgeConfig.enabled || !bridgeConfig.emitUserActions) return ++ val p = player ++ bridgeDispatcher?.emit( ++ BridgeEvent.UserAction( ++ sessionId = bridgeConfig.sessionId, ++ ts = System.currentTimeMillis(), ++ uri = p?.currentMediaItem?.localConfiguration?.uri?.toString(), ++ action = action, ++ payload = payload ++ ) ++ ) ++ } +*** End Patch +*** Begin Patch +*** Update File: app/src/main/java/top/rootu/dddplayer/ui/PlayerFragment.kt +@@ private fun setupControls() { +- ui.playPauseButton.setOnClickListener { viewModel.togglePlayPause() } +- ui.rewindButton.setOnClickListener { viewModel.seekBack() } +- ui.ffwdButton.setOnClickListener { viewModel.seekForward() } +- ui.prevButton.setOnClickListener { viewModel.prevTrack() } +- ui.nextButton.setOnClickListener { viewModel.nextTrack() } ++ ui.playPauseButton.setOnClickListener { ++ viewModel.emitUserAction("play_pause_click") ++ viewModel.togglePlayPause() ++ } ++ ui.rewindButton.setOnClickListener { ++ viewModel.emitUserAction("rewind_click") ++ viewModel.seekBack() ++ } ++ ui.ffwdButton.setOnClickListener { ++ viewModel.emitUserAction("ffwd_click") ++ viewModel.seekForward() ++ } ++ ui.prevButton.setOnClickListener { ++ viewModel.emitUserAction("prev_click") ++ viewModel.prevTrack() ++ } ++ ui.nextButton.setOnClickListener { ++ viewModel.emitUserAction("next_click") ++ viewModel.nextTrack() ++ } +@@ +- ui.buttonSpeed.setOnClickListener { +- ui.hideControls() +- showPlaybackSpeedMenu() +- } ++ ui.buttonSpeed.setOnClickListener { ++ viewModel.emitUserAction("open_speed_menu") ++ ui.hideControls() ++ showPlaybackSpeedMenu() ++ } +@@ +- ui.buttonResize.setOnClickListener { +- ui.hideControls() +- showResizeMenu() +- } ++ ui.buttonResize.setOnClickListener { ++ viewModel.emitUserAction("open_resize_menu") ++ ui.hideControls() ++ showResizeMenu() ++ } +@@ +- ui.buttonAudio.setOnClickListener { +- ui.hideControls() +- showAudioTrackMenu() +- } ++ ui.buttonAudio.setOnClickListener { ++ viewModel.emitUserAction("open_audio_menu") ++ ui.hideControls() ++ showAudioTrackMenu() ++ } +@@ +- ui.buttonSubs.setOnClickListener { +- ui.hideControls() +- showSubtitlesMenu() +- } ++ ui.buttonSubs.setOnClickListener { ++ viewModel.emitUserAction("open_subtitles_menu") ++ ui.hideControls() ++ showSubtitlesMenu() ++ } +@@ +- ui.buttonSettings.setOnClickListener { +- ui.hideControls() +- showMainMenu() +- } ++ ui.buttonSettings.setOnClickListener { ++ viewModel.emitUserAction("open_main_menu") ++ ui.hideControls() ++ showMainMenu() ++ } +@@ +- ui.buttonPlaylist.setOnClickListener { showPlaylist() } +- ui.buttonQuality.setOnClickListener { showQualityPopup() } ++ ui.buttonPlaylist.setOnClickListener { ++ viewModel.emitUserAction("open_playlist_dialog") ++ showPlaylist() ++ } ++ ui.buttonQuality.setOnClickListener { ++ viewModel.emitUserAction("open_quality_menu") ++ showQualityPopup() ++ } +*** End Patch +*** Begin Patch +*** Update File: app/src/main/java/top/rootu/dddplayer/ui/PlayerFragment.kt +@@ private val performDoubleTapSeekRunnable = Runnable { +- val current = viewModel.currentPosition.value ?: 0L +- val target = current + (doubleTapSeekSeconds * 1000L) +- viewModel.seekTo(target.coerceAtLeast(0)) +- doubleTapSeekSeconds = 0 ++ val current = viewModel.currentPosition.value ?: 0L ++ val target = current + (doubleTapSeekSeconds * 1000L) ++ viewModel.emitUserAction( ++ if (doubleTapSeekSeconds > 0) "double_tap_seek_forward" else "double_tap_seek_backward", ++ mapOf("target_position" to target.coerceAtLeast(0).toString()) ++ ) ++ viewModel.seekTo(target.coerceAtLeast(0)) ++ doubleTapSeekSeconds = 0 +*** End Patch +*** Begin Patch +*** Update File: app/src/main/java/top/rootu/dddplayer/ui/PlayerFragment.kt +@@ private fun finishHorizontalSwipe() { +- if (currentSwipeIsExit) { +- // Логика выхода +- requireActivity().finish() +- } else { +- // Переключение трека +- if (isNext) viewModel.nextTrack() else viewModel.prevTrack() +- } ++ if (currentSwipeIsExit) { ++ viewModel.emitUserAction("swipe_exit") ++ // Логика выхода ++ requireActivity().finish() ++ } else { ++ viewModel.emitUserAction( ++ if (isNext) "swipe_playlist_next" else "swipe_playlist_prev" ++ ) ++ // Переключение трека ++ if (isNext) viewModel.nextTrack() else viewModel.prevTrack() ++ } +*** End Patch +*** Begin Patch +*** Update File: app/src/main/java/top/rootu/dddplayer/ui/PlayerFragment.kt +@@ ui.seekBar.setOnSeekBarChangeListener(object : android.widget.SeekBar.OnSeekBarChangeListener { +- override fun onStopTrackingTouch(seekBar: android.widget.SeekBar?) { +- seekBar?.progress?.let { viewModel.seekTo(it.toLong()) } +- viewModel.isUserInteracting = false +- timerController.resetControlsTimer() +- } ++ override fun onStopTrackingTouch(seekBar: android.widget.SeekBar?) { ++ seekBar?.progress?.let { ++ viewModel.emitUserAction( ++ "seekbar_release", ++ mapOf("target_position" to it.toLong().toString()) ++ ) ++ viewModel.seekTo(it.toLong()) ++ } ++ viewModel.isUserInteracting = false ++ timerController.resetControlsTimer() ++ } +*** End Patch diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 755e08f..4093de9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,6 +14,7 @@ fragmentKtx = "1.8.9" # Версия Media3. Все модули должны быть одной версии. media3 = "1.9.0" okhttp = "5.3.2" +gson = "2.11.0" # Версия Room Database. Все модули должны быть одной версии. roomVersion = "2.8.4" @@ -37,6 +38,7 @@ material = { group = "com.google.android.material", name = "material", version.r androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" } androidx-fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragmentKtx" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } +gson = { group = "com.google.code.gson", name = "gson", version.ref = "gson" } # Зависимости Media3 media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } diff --git a/gradlew b/gradlew index 4f906e0..00ff346 100755 --- a/gradlew +++ b/gradlew @@ -43,6 +43,22 @@ cd "$SAVED" >/dev/null APP_NAME="Gradle" APP_BASE_NAME=`basename "$0"` +# Prefer JDK 17 when running on unsupported bleeding-edge JDKs (e.g. 25.x) +if [ -x "${JAVA_HOME:-}/bin/java" ] ; then + CURRENT_JAVA_VERSION=`"$JAVA_HOME/bin/java" -version 2>&1 | sed -n '1s/.*version "\([0-9][0-9]*\)\..*/\1/p'` +else + CURRENT_JAVA_VERSION=`java -version 2>&1 | sed -n '1s/.*version "\([0-9][0-9]*\)\..*/\1/p'` +fi +if [ -n "$CURRENT_JAVA_VERSION" ] && [ "$CURRENT_JAVA_VERSION" -ge 25 ] 2>/dev/null ; then + if [ -d "$HOME/.local/share/mise/installs/java" ] ; then + JDK17_CANDIDATE=`find "$HOME/.local/share/mise/installs/java" -maxdepth 1 -type d -name '17*' | sort -V | tail -n 1` + if [ -n "$JDK17_CANDIDATE" ] && [ -x "$JDK17_CANDIDATE/bin/java" ] ; then + JAVA_HOME="$JDK17_CANDIDATE" + export JAVA_HOME + fi + fi +fi + # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'