Joburgess 1 săptămână în urmă
părinte
comite
4501a95aa3
31 a modificat fișierele cu 412 adăugiri și 36 ștergeri
  1. 1 0
      .gitignore
  2. BIN
      WebCam-release-v1.0.0.apk
  3. 19 0
      app/build.gradle.kts
  4. 1 0
      app/proguard-rules.pro
  5. 2 1
      app/src/main/AndroidManifest.xml
  6. 1 0
      app/src/main/aidl/com/joe/camera/webcam/IUserService.aidl
  7. 12 1
      app/src/main/java/com/joe/camera/webcam/MainActivity.kt
  8. 11 3
      app/src/main/java/com/joe/camera/webcam/MainViewModel.kt
  9. 3 0
      app/src/main/java/com/joe/camera/webcam/UserService.kt
  10. 94 9
      app/src/main/java/com/joe/camera/webcam/camera/CameraController.kt
  11. 41 4
      app/src/main/java/com/joe/camera/webcam/data/MediaRepository.kt
  12. 26 0
      app/src/main/java/com/joe/camera/webcam/system/DisplayToggle.kt
  13. 8 3
      app/src/main/java/com/joe/camera/webcam/system/ScreenController.kt
  14. 70 10
      app/src/main/java/com/joe/camera/webcam/ui/HomeScreen.kt
  15. 77 0
      app/src/main/java/com/joe/camera/webcam/ui/IndustrialSlider.kt
  16. 17 5
      app/src/main/java/com/joe/camera/webcam/ui/MediaScreens.kt
  17. 14 0
      app/src/main/res/drawable/ic_launcher_foreground.xml
  18. 5 0
      app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
  19. 5 0
      app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
  20. BIN
      app/src/main/res/mipmap-hdpi/ic_launcher.webp
  21. BIN
      app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
  22. BIN
      app/src/main/res/mipmap-mdpi/ic_launcher.webp
  23. BIN
      app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
  24. BIN
      app/src/main/res/mipmap-xhdpi/ic_launcher.webp
  25. BIN
      app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
  26. BIN
      app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
  27. BIN
      app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
  28. BIN
      app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
  29. BIN
      app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
  30. 4 0
      app/src/main/res/values/ic_launcher_background.xml
  31. 1 0
      app/src/test/java/com/joe/camera/webcam/data/MediaRepositoryTest.kt

+ 1 - 0
.gitignore

@@ -2,6 +2,7 @@
 .gradle/
 .idea/
 local.properties
+signing.properties
 build/
 app/build/
 captures/

BIN
WebCam-release-v1.0.0.apk


+ 19 - 0
app/build.gradle.kts

@@ -1,9 +1,16 @@
+import java.util.Properties
+
 plugins {
     id("com.android.application")
     id("org.jetbrains.kotlin.android")
     id("org.jetbrains.kotlin.plugin.compose")
 }
 
+val signingPropertiesFile = rootProject.file("signing.properties")
+val signingProperties = Properties().apply {
+    if (signingPropertiesFile.exists()) signingPropertiesFile.inputStream().use(::load)
+}
+
 android {
     namespace = "com.joe.camera.webcam"
     compileSdk = 35
@@ -30,10 +37,22 @@ android {
     }
     kotlinOptions { jvmTarget = "17" }
 
+    signingConfigs {
+        if (signingPropertiesFile.exists()) {
+            create("production") {
+                storeFile = file(signingProperties.getProperty("storeFile"))
+                storePassword = signingProperties.getProperty("storePassword")
+                keyAlias = signingProperties.getProperty("keyAlias")
+                keyPassword = signingProperties.getProperty("keyPassword")
+            }
+        }
+    }
+
     buildTypes {
         release {
             isMinifyEnabled = true
             isShrinkResources = true
+            if (signingPropertiesFile.exists()) signingConfig = signingConfigs.getByName("production")
             proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
         }
     }

+ 1 - 0
app/proguard-rules.pro

@@ -2,4 +2,5 @@
 -keep class com.joe.camera.webcam.IUserService { *; }
 -keep class com.joe.camera.webcam.IUserService$Stub { *; }
 -keep class com.joe.camera.webcam.UserService { *; }
+-keep class com.joe.camera.webcam.system.DisplayToggle { *; }
 -keepattributes *Annotation*

+ 2 - 1
app/src/main/AndroidManifest.xml

@@ -18,8 +18,9 @@
     <application
         android:name=".WebCamApplication"
         android:allowBackup="true"
-        android:icon="@drawable/ic_tile"
+        android:icon="@mipmap/ic_launcher"
         android:label="@string/app_name"
+        android:roundIcon="@mipmap/ic_launcher_round"
         android:supportsRtl="true"
         android:theme="@style/Theme.WebCam"
         android:usesCleartextTraffic="true">

+ 1 - 0
app/src/main/aidl/com/joe/camera/webcam/IUserService.aidl

@@ -3,4 +3,5 @@ package com.joe.camera.webcam;
 interface IUserService {
     void destroy() = 16777114;
     String exec(String command) = 1;
+    boolean setDisplayPowerMode(int mode) = 2;
 }

+ 12 - 1
app/src/main/java/com/joe/camera/webcam/MainActivity.kt

@@ -14,6 +14,7 @@ import com.joe.camera.webcam.ui.WebCamApp
 
 class MainActivity : ComponentActivity() {
     private val viewModel: MainViewModel by viewModels()
+    private var backgroundExitRequested = false
     private val permissionLauncher = registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
         if (permissions[Manifest.permission.CAMERA] == true) viewModel.startBle()
     }
@@ -46,5 +47,15 @@ class MainActivity : ComponentActivity() {
         KeyEvent.KEYCODE_VOLUME_DOWN -> { viewModel.toggleBlackScreen(); true }
         else -> super.onKeyUp(keyCode, event)
     }
-    override fun onPause() { viewModel.stopRecording(); super.onPause() }
+    override fun onStop() {
+        super.onStop()
+        if (!isChangingConfigurations && !isFinishing && !backgroundExitRequested) {
+            backgroundExitRequested = true
+            viewModel.exitAppWhenRecordingSaved {
+                runOnUiThread {
+                    finishAndRemoveTask()
+                }
+            }
+        }
+    }
 }

+ 11 - 3
app/src/main/java/com/joe/camera/webcam/MainViewModel.kt

@@ -49,7 +49,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
         else if (_uiState.value.cameraState != CameraState.WORKING) cameraController.toggleRecording()
     }
     fun stopRecording() { cameraController.stopRecording() }
+    fun exitAppWhenRecordingSaved(onReadyToExit: () -> Unit) {
+        if (_uiState.value.remoteMode) onReadyToExit() else cameraController.stopRecording(onReadyToExit)
+    }
     fun togglePreview() { cameraController.togglePreview(); _uiState.value = _uiState.value.copy(previewPaused = cameraController.previewPaused) }
+    fun setCameraZoom(value: Float) = cameraController.setLinearZoom(value)
     fun toggleRemoteMode() {
         val remote = !_uiState.value.remoteMode
         preferences.edit().putBoolean("remote_mode", remote).apply()
@@ -60,9 +64,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
     fun toggleBlackScreen() {
         val off = !_uiState.value.blackScreen
         _uiState.value = _uiState.value.copy(blackScreen = off)
-        if (off) viewModelScope.launch(Dispatchers.IO) {
-            val mode = screenController.togglePhysicalScreen(true)
-            message(when (mode) { ScreenControlMode.SHIZUKU -> "已通过 Shizuku 关屏"; ScreenControlMode.ROOT -> "已通过 root 关屏"; ScreenControlMode.OVERLAY -> "已使用应用内黑屏" })
+        viewModelScope.launch(Dispatchers.IO) {
+            val mode = screenController.setPhysicalScreen(off)
+            message(when (mode) {
+                ScreenControlMode.SHIZUKU -> if (off) "已通过 Shizuku 关闭显示" else "已通过 Shizuku 恢复显示"
+                ScreenControlMode.ROOT -> if (off) "已通过 root 关闭显示" else "已通过 root 恢复显示"
+                ScreenControlMode.OVERLAY -> if (off) "已使用应用内黑屏" else "显示已恢复"
+            })
         }
     }
     fun loadMedia() = viewModelScope.launch(Dispatchers.IO) { _uiState.value = _uiState.value.copy(media = repository.loadMedia()) }

+ 3 - 0
app/src/main/java/com/joe/camera/webcam/UserService.kt

@@ -1,9 +1,12 @@
 package com.joe.camera.webcam
 
+import com.joe.camera.webcam.system.DisplayToggle
+
 class UserService : IUserService.Stub() {
     override fun destroy() = Unit
     override fun exec(command: String?): String = runCatching {
         val process = Runtime.getRuntime().exec(arrayOf("sh", "-c", command.orEmpty()))
         process.inputStream.bufferedReader().readText()
     }.getOrElse { it.message.orEmpty() }
+    override fun setDisplayPowerMode(mode: Int): Boolean = DisplayToggle.setDisplayPowerMode(mode)
 }

+ 94 - 9
app/src/main/java/com/joe/camera/webcam/camera/CameraController.kt

@@ -1,15 +1,21 @@
 package com.joe.camera.webcam.camera
 
 import android.content.Context
+import android.hardware.camera2.CameraCharacteristics
+import android.hardware.camera2.CaptureRequest
 import android.provider.MediaStore
+import android.util.Range
 import androidx.camera.core.Camera
 import androidx.camera.core.CameraSelector
 import androidx.camera.core.ImageCapture
 import androidx.camera.core.ImageCaptureException
 import androidx.camera.core.Preview
 import androidx.camera.lifecycle.ProcessCameraProvider
-import androidx.camera.video.MediaStoreOutputOptions
-import androidx.camera.video.FallbackStrategy
+import androidx.camera.camera2.interop.Camera2CameraControl
+import androidx.camera.camera2.interop.Camera2CameraInfo
+import androidx.camera.camera2.interop.CaptureRequestOptions
+import androidx.camera.camera2.interop.ExperimentalCamera2Interop
+import androidx.camera.video.FileOutputOptions
 import androidx.camera.video.PendingRecording
 import androidx.camera.video.Quality
 import androidx.camera.video.QualitySelector
@@ -23,14 +29,18 @@ import androidx.lifecycle.LifecycleOwner
 import com.joe.camera.webcam.data.MediaRepository
 import com.joe.camera.webcam.model.CameraState
 import java.util.concurrent.Executor
+import java.util.concurrent.Executors
+import java.io.File
 
 class CameraController(private val context: Context, private val onState: (CameraState) -> Unit, private val onMessage: (String) -> Unit) {
     private val executor: Executor = ContextCompat.getMainExecutor(context)
+    private val fileExecutor = Executors.newSingleThreadExecutor()
     private var provider: ProcessCameraProvider? = null
     private var preview: Preview? = null
     private var imageCapture: ImageCapture? = null
     private var videoCapture: VideoCapture<Recorder>? = null
     private var recording: Recording? = null
+    private var afterRecordingStopped: (() -> Unit)? = null
     private var camera: Camera? = null
     private var lifecycleOwner: LifecycleOwner? = null
     private var previewView: PreviewView? = null
@@ -38,6 +48,7 @@ class CameraController(private val context: Context, private val onState: (Camer
         private set
     var previewPaused = false
         private set
+    private var linearZoom = 0f
 
     fun bind(owner: LifecycleOwner, view: PreviewView, facing: Int = lensFacing) {
         lifecycleOwner = owner
@@ -49,11 +60,16 @@ class CameraController(private val context: Context, private val onState: (Camer
                 provider = future.get()
                 provider?.unbindAll()
                 preview = Preview.Builder().build().also { it.surfaceProvider = view.surfaceProvider }
-                imageCapture = ImageCapture.Builder().setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY).build()
-                val recorder = Recorder.Builder().setQualitySelector(QualitySelector.fromOrderedList(listOf(Quality.UHD, Quality.FHD, Quality.HD), FallbackStrategy.lowerQualityOrHigherThan(Quality.HD))).build()
+                imageCapture = ImageCapture.Builder()
+                    .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
+                    .setFlashMode(ImageCapture.FLASH_MODE_OFF)
+                    .build()
+                val recorder = Recorder.Builder().setQualitySelector(QualitySelector.from(Quality.FHD)).build()
                 videoCapture = VideoCapture.withOutput(recorder)
                 camera = provider?.bindToLifecycle(owner, CameraSelector.Builder().requireLensFacing(facing).build(), preview, imageCapture, videoCapture)
                 camera?.cameraControl?.enableTorch(false)
+                camera?.cameraControl?.setLinearZoom(linearZoom)
+                camera?.let(::configureLowLight)
                 onState(CameraState.FREE)
             }.onFailure { onState(CameraState.ERROR); onMessage("相机初始化失败:${it.message.orEmpty()}") }
         }, executor)
@@ -66,20 +82,67 @@ class CameraController(private val context: Context, private val onState: (Camer
         preview?.surfaceProvider = if (previewPaused) null else previewView?.surfaceProvider
     }
 
+    fun setLinearZoom(value: Float) {
+        linearZoom = value.coerceIn(0f, 1f)
+        camera?.cameraControl?.setLinearZoom(linearZoom)
+    }
+
+    @OptIn(ExperimentalCamera2Interop::class)
+    private fun configureLowLight(boundCamera: Camera) {
+        runCatching {
+            val cameraInfo = Camera2CameraInfo.from(boundCamera.cameraInfo)
+            val availableFpsRanges = cameraInfo.getCameraCharacteristic(
+                CameraCharacteristics.CONTROL_AE_AVAILABLE_TARGET_FPS_RANGES,
+            ) ?: emptyArray()
+            val lowLightRange = availableFpsRanges
+                .filter { it.lower <= 15 && it.upper >= 30 }
+                .minByOrNull { it.lower }
+
+            val noiseReductionModes = cameraInfo.getCameraCharacteristic(
+                CameraCharacteristics.NOISE_REDUCTION_AVAILABLE_NOISE_REDUCTION_MODES,
+            ) ?: intArrayOf()
+            val options = CaptureRequestOptions.Builder().apply {
+                lowLightRange?.let {
+                    setCaptureRequestOption(
+                        CaptureRequest.CONTROL_AE_TARGET_FPS_RANGE,
+                        Range(it.lower, it.upper),
+                    )
+                }
+                if (noiseReductionModes.contains(CaptureRequest.NOISE_REDUCTION_MODE_HIGH_QUALITY)) {
+                    setCaptureRequestOption(
+                        CaptureRequest.NOISE_REDUCTION_MODE,
+                        CaptureRequest.NOISE_REDUCTION_MODE_HIGH_QUALITY,
+                    )
+                }
+            }.build()
+            Camera2CameraControl.from(boundCamera.cameraControl).setCaptureRequestOptions(options)
+        }
+    }
+
     fun takePicture() {
         val capture = imageCapture ?: return onMessage("相机尚未就绪")
+        capture.flashMode = ImageCapture.FLASH_MODE_OFF
+        camera?.cameraControl?.enableTorch(false)
         onState(CameraState.WORKING)
         val options = ImageCapture.OutputFileOptions.Builder(context.contentResolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaRepository(context).imageValues()).build()
         capture.takePicture(options, executor, object : ImageCapture.OnImageSavedCallback {
-            override fun onImageSaved(output: ImageCapture.OutputFileResults) { onState(CameraState.FREE); onMessage("照片已保存到 Download/jf") }
-            override fun onError(exception: ImageCaptureException) { onState(CameraState.ERROR); onMessage("拍照失败:${exception.message}") }
+            override fun onImageSaved(output: ImageCapture.OutputFileResults) {
+                onState(CameraState.FREE)
+                onMessage("照片已保存到 Download/jf")
+            }
+            override fun onError(exception: ImageCaptureException) {
+                onState(CameraState.ERROR)
+                onMessage("拍照失败:${exception.message}")
+            }
         })
     }
 
     fun toggleRecording() {
         recording?.let { it.stop(); return }
         val capture = videoCapture ?: return onMessage("相机尚未就绪")
-        val options = MediaStoreOutputOptions.Builder(context.contentResolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI).setContentValues(MediaRepository(context).videoValues()).build()
+        camera?.cameraControl?.enableTorch(false)
+        val temporaryFile = File.createTempFile("webcam-recording-", ".mp4", context.cacheDir)
+        val options = FileOutputOptions.Builder(temporaryFile).build()
         val pending: PendingRecording = capture.output.prepareRecording(context, options)
         onState(CameraState.WORKING)
         recording = pending.start(executor) { event ->
@@ -87,13 +150,35 @@ class CameraController(private val context: Context, private val onState: (Camer
                 is VideoRecordEvent.Start -> onState(CameraState.RECORDING)
                 is VideoRecordEvent.Finalize -> {
                     recording = null
-                    if (event.hasError()) { onState(CameraState.ERROR); onMessage("录像失败:${event.error}") }
-                    else { onState(CameraState.FREE); onMessage("视频已保存到 Download/jf") }
+                    if (event.hasError()) {
+                        temporaryFile.delete()
+                        onState(CameraState.ERROR)
+                        onMessage("录像失败:${event.error}")
+                        afterRecordingStopped?.also { callback -> afterRecordingStopped = null; callback() }
+                    } else {
+                        onState(CameraState.WORKING)
+                        fileExecutor.execute {
+                            val saved = MediaRepository(context).importRecordedVideo(temporaryFile)
+                            executor.execute {
+                                if (saved) { onState(CameraState.FREE); onMessage("视频已保存到 Download/jf") }
+                                else { onState(CameraState.ERROR); onMessage("录像保存失败") }
+                                afterRecordingStopped?.also { callback -> afterRecordingStopped = null; callback() }
+                            }
+                        }
+                    }
                 }
             }
         }
     }
 
     fun stopRecording() { recording?.stop() }
+    fun stopRecording(onStopped: () -> Unit) {
+        val activeRecording = recording
+        if (activeRecording == null) onStopped()
+        else {
+            afterRecordingStopped = onStopped
+            activeRecording.stop()
+        }
+    }
     fun release() { recording?.stop(); recording = null; provider?.unbindAll() }
 }

+ 41 - 4
app/src/main/java/com/joe/camera/webcam/data/MediaRepository.kt

@@ -19,11 +19,41 @@ class MediaRepository(private val context: Context) {
         val RELATIVE_DIR = "${Environment.DIRECTORY_DOWNLOADS}/jf"
         const val IMAGE_MIME = "image/jpeg"
         const val VIDEO_MIME = "video/mp4"
+        const val DISGUISED_VIDEO_MIME = "application/octet-stream"
         fun timestamp(): String = SimpleDateFormat("yyyyMMddHHmmssSSS", Locale.US).format(Date())
     }
 
     fun imageValues() = contentValues("${timestamp()}.jp", IMAGE_MIME)
-    fun videoValues() = contentValues("${timestamp()}.jv", VIDEO_MIME)
+    fun videoValues() = contentValues("${timestamp()}.jv", DISGUISED_VIDEO_MIME)
+
+    fun importRecordedVideo(source: File): Boolean {
+        val values = videoValues().apply {
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) put(MediaStore.MediaColumns.IS_PENDING, 1)
+        }
+        val resolver = context.contentResolver
+        val collection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            MediaStore.Downloads.EXTERNAL_CONTENT_URI
+        } else {
+            MediaStore.Files.getContentUri("external")
+        }
+        val uri = runCatching { resolver.insert(collection, values) }.getOrNull() ?: run {
+            source.delete()
+            return false
+        }
+        return try {
+            resolver.openOutputStream(uri, "w")?.use { output -> source.inputStream().use { it.copyTo(output) } }
+                ?: throw IllegalStateException("无法打开媒体输出流")
+            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+                resolver.update(uri, ContentValues().apply { put(MediaStore.MediaColumns.IS_PENDING, 0) }, null, null)
+            }
+            true
+        } catch (_: Exception) {
+            resolver.delete(uri, null, null)
+            false
+        } finally {
+            source.delete()
+        }
+    }
 
     private fun contentValues(name: String, mime: String) = ContentValues().apply {
         put(MediaStore.MediaColumns.DISPLAY_NAME, name)
@@ -40,10 +70,15 @@ class MediaRepository(private val context: Context) {
         val result = mutableListOf<MediaItem>()
         queryCollection(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, MediaKind.IMAGE, result)
         queryCollection(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, MediaKind.VIDEO, result)
-        return result.sortedByDescending(MediaItem::modifiedSeconds)
+        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            queryCollection(MediaStore.Downloads.EXTERNAL_CONTENT_URI, null, result)
+        }
+        return result
+            .distinctBy { "${it.kind}:${it.displayName}:${it.modifiedSeconds}" }
+            .sortedByDescending(MediaItem::modifiedSeconds)
     }
 
-    private fun queryCollection(collection: Uri, kind: MediaKind, output: MutableList<MediaItem>) {
+    private fun queryCollection(collection: Uri, expectedKind: MediaKind?, output: MutableList<MediaItem>) {
         val projection = arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DISPLAY_NAME, MediaStore.MediaColumns.DATE_MODIFIED)
         val selection: String
         val args: Array<String>
@@ -63,7 +98,9 @@ class MediaRepository(private val context: Context) {
                 while (cursor.moveToNext()) {
                     val name = cursor.getString(nameColumn)
                     val actualKind = if (name.endsWith(".jp", true)) MediaKind.IMAGE else MediaKind.VIDEO
-                    if (actualKind == kind) output += MediaItem(cursor.getLong(idColumn), ContentUris.withAppendedId(collection, cursor.getLong(idColumn)), name, kind, cursor.getLong(dateColumn))
+                    if (expectedKind == null || actualKind == expectedKind) {
+                        output += MediaItem(cursor.getLong(idColumn), ContentUris.withAppendedId(collection, cursor.getLong(idColumn)), name, actualKind, cursor.getLong(dateColumn))
+                    }
                 }
             }
         }

+ 26 - 0
app/src/main/java/com/joe/camera/webcam/system/DisplayToggle.kt

@@ -0,0 +1,26 @@
+package com.joe.camera.webcam.system
+
+import android.os.Build
+import android.os.IBinder
+
+/** Equivalent of the reference project's DisplayToggle.dex, embedded in the APK. */
+object DisplayToggle {
+    fun setDisplayPowerMode(mode: Int): Boolean = runCatching {
+        val surfaceControl = Class.forName("android.view.SurfaceControl")
+        val displayToken = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+            surfaceControl.getMethod("getInternalDisplayToken").invoke(null) as IBinder
+        } else {
+            surfaceControl.getMethod("getBuiltInDisplay", Int::class.javaPrimitiveType).invoke(null, 0) as IBinder
+        }
+        surfaceControl
+            .getMethod("setDisplayPowerMode", IBinder::class.java, Int::class.javaPrimitiveType)
+            .invoke(null, displayToken, mode)
+        true
+    }.getOrDefault(false)
+
+    @JvmStatic
+    fun main(args: Array<String>) {
+        val mode = args.firstOrNull()?.toIntOrNull() ?: return
+        if (!setDisplayPowerMode(mode)) throw IllegalStateException("Unable to set display power mode")
+    }
+}

+ 8 - 3
app/src/main/java/com/joe/camera/webcam/system/ScreenController.kt

@@ -35,9 +35,14 @@ class ScreenController(private val context: Context) {
         }
     }
 
-    fun togglePhysicalScreen(off: Boolean): ScreenControlMode {
-        if (off && service != null) { runCatching { service?.exec("input keyevent 26") }; return ScreenControlMode.SHIZUKU }
-        if (off && hasRoot()) { runCatching { Runtime.getRuntime().exec(arrayOf("su", "-c", "input keyevent 26")) }; return ScreenControlMode.ROOT }
+    fun setPhysicalScreen(off: Boolean): ScreenControlMode {
+        val mode = if (off) 0 else 2
+        if (service != null && runCatching { service?.setDisplayPowerMode(mode) == true }.getOrDefault(false)) return ScreenControlMode.SHIZUKU
+        if (hasRoot()) {
+            val apk = context.applicationInfo.sourceDir.replace("'", "'\\''")
+            val command = "CLASSPATH='$apk' app_process / com.joe.camera.webcam.system.DisplayToggle $mode"
+            if (runCatching { Runtime.getRuntime().exec(arrayOf("su", "-c", command)).waitFor() == 0 }.getOrDefault(false)) return ScreenControlMode.ROOT
+        }
         return ScreenControlMode.OVERLAY
     }
 

+ 70 - 10
app/src/main/java/com/joe/camera/webcam/ui/HomeScreen.kt

@@ -53,16 +53,19 @@ body,.kr-mobile-layout,.home-tab-bar-item{background:#212121!important}
 @Composable
 fun HomeScreen(viewModel: MainViewModel, state: MainUiState, onMedia: () -> Unit) {
     val lifecycleOwner = LocalLifecycleOwner.current
+    val context = LocalContext.current
+    val positionPreferences = remember(context) { context.getSharedPreferences("floating_controls", Context.MODE_PRIVATE) }
     var webView by remember { mutableStateOf<WebView?>(null) }
     var progress by remember { mutableFloatStateOf(0f) }
     var menuExpanded by remember { mutableStateOf(false) }
     var settingsOpen by remember { mutableStateOf(false) }
     var previewOpacity by remember { mutableFloatStateOf(0.02f) }
     var previewWidth by remember { mutableFloatStateOf(80f) }
-    var previewX by remember { mutableFloatStateOf(100f) }
-    var previewY by remember { mutableFloatStateOf(200f) }
-    var buttonX by remember { mutableFloatStateOf(300f) }
-    var buttonY by remember { mutableFloatStateOf(580f) }
+    var cameraZoom by remember { mutableFloatStateOf(0f) }
+    var previewX by remember { mutableFloatStateOf(-1f) }
+    var previewBottom by remember { mutableFloatStateOf(-1f) }
+    var buttonX by remember { mutableFloatStateOf(positionPreferences.getFloat("menu_x", -1f)) }
+    var buttonY by remember { mutableFloatStateOf(positionPreferences.getFloat("menu_y", -1f)) }
     val density = LocalDensity.current
     val activity = LocalContext.current.findActivity()
 
@@ -83,6 +86,18 @@ fun HomeScreen(viewModel: MainViewModel, state: MainUiState, onMedia: () -> Unit
     BoxWithConstraints(Modifier.fillMaxSize().background(Color(0xFF17171A))) {
         val maxWidthPx = with(density) { maxWidth.toPx() }
         val maxHeightPx = with(density) { maxHeight.toPx() }
+        val buttonSizePx = with(density) { 42.dp.toPx() }
+        val previewWidthPx = with(density) { previewWidth.dp.toPx() }
+        val previewHeightPx = previewWidthPx * 16f / 9f
+        val previewTop = maxHeightPx - previewBottom - previewHeightPx
+        LaunchedEffect(maxWidthPx, maxHeightPx) {
+            if (previewX < 0f) previewX = with(density) { 100.dp.toPx() }
+            if (previewBottom < 0f) previewBottom = with(density) { 150.dp.toPx() }
+            if (buttonX < 0f) buttonX = maxWidthPx - buttonSizePx
+            if (buttonY < 0f) buttonY = maxHeightPx * 0.7f
+            buttonX = buttonX.coerceIn(0f, maxWidthPx - buttonSizePx)
+            buttonY = buttonY.coerceIn(0f, maxHeightPx - buttonSizePx)
+        }
         AndroidView(
             modifier = Modifier.fillMaxSize(),
             factory = { context ->
@@ -107,13 +122,25 @@ fun HomeScreen(viewModel: MainViewModel, state: MainUiState, onMedia: () -> Unit
         if (!state.remoteMode) {
             AndroidView(
                 modifier = Modifier
-                    .offset { IntOffset(previewX.roundToInt(), previewY.roundToInt()) }
+                    .offset { IntOffset(previewX.roundToInt(), previewTop.roundToInt()) }
                     .width(previewWidth.dp)
                     .aspectRatio(9f / 16f)
                     .alpha(previewOpacity)
                     .clip(RoundedCornerShape((previewWidth / 9f).dp))
-                    .pointerInput(Unit) { detectDragGestures { change, drag -> change.consume(); previewX = (previewX + drag.x).coerceIn(0f, maxWidthPx - 20); previewY = (previewY + drag.y).coerceIn(0f, maxHeightPx - 20) } },
-                factory = { context -> PreviewView(context).also { viewModel.bindCamera(lifecycleOwner, it) } },
+                    .pointerInput(maxWidthPx, maxHeightPx, previewWidthPx, previewHeightPx) {
+                        detectDragGestures { change, drag ->
+                            change.consume()
+                            previewX = (previewX + drag.x).coerceIn(0f, (maxWidthPx - previewWidthPx).coerceAtLeast(0f))
+                            previewBottom = (previewBottom - drag.y).coerceIn(0f, (maxHeightPx - previewHeightPx).coerceAtLeast(0f))
+                        }
+                    },
+                factory = { context ->
+                    PreviewView(context).apply {
+                        implementationMode = PreviewView.ImplementationMode.COMPATIBLE
+                        scaleType = PreviewView.ScaleType.FILL_CENTER
+                        viewModel.bindCamera(lifecycleOwner, this)
+                    }
+                },
             )
         }
 
@@ -121,7 +148,22 @@ fun HomeScreen(viewModel: MainViewModel, state: MainUiState, onMedia: () -> Unit
             Surface(
                 modifier = Modifier.size(42.dp)
                     .pointerInput(Unit) { detectTapGestures(onTap = { viewModel.toggleRecording() }, onDoubleTap = { menuExpanded = !menuExpanded }, onLongPress = { settingsOpen = true }) }
-                    .pointerInput(Unit) { detectDragGestures { change, drag -> change.consume(); buttonX = (buttonX + drag.x).coerceIn(0f, maxWidthPx - 42.dp.toPx()); buttonY = (buttonY + drag.y).coerceIn(0f, maxHeightPx - 42.dp.toPx()) } },
+                    .pointerInput(maxWidthPx, maxHeightPx) {
+                        detectDragGestures(
+                            onDragEnd = {
+                                buttonX = if (buttonX + buttonSizePx / 2f < maxWidthPx / 2f) 0f else maxWidthPx - buttonSizePx
+                                positionPreferences.edit().putFloat("menu_x", buttonX).putFloat("menu_y", buttonY).apply()
+                            },
+                            onDragCancel = {
+                                buttonX = if (buttonX + buttonSizePx / 2f < maxWidthPx / 2f) 0f else maxWidthPx - buttonSizePx
+                                positionPreferences.edit().putFloat("menu_x", buttonX).putFloat("menu_y", buttonY).apply()
+                            },
+                        ) { change, drag ->
+                            change.consume()
+                            buttonX = (buttonX + drag.x).coerceIn(0f, maxWidthPx - buttonSizePx)
+                            buttonY = (buttonY + drag.y).coerceIn(0f, maxHeightPx - buttonSizePx)
+                        }
+                    },
                 shape = CircleShape,
                 color = Color.White,
                 shadowElevation = 8.dp,
@@ -141,9 +183,27 @@ fun HomeScreen(viewModel: MainViewModel, state: MainUiState, onMedia: () -> Unit
     if (settingsOpen) ModalBottomSheet(onDismissRequest = { settingsOpen = false }) {
         Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp)) {
             Text("预览透明度 ${(previewOpacity * 100).roundToInt()}%")
-            Slider(value = previewOpacity, onValueChange = { previewOpacity = it })
+            IndustrialSlider(
+                value = previewOpacity,
+                onValueChange = { previewOpacity = it },
+                modifier = Modifier.padding(vertical = 3.dp),
+            )
             Text("预览宽度 ${previewWidth.roundToInt()} dp")
-            Slider(value = previewWidth, onValueChange = { previewWidth = it }, valueRange = 15f..360f)
+            IndustrialSlider(
+                value = previewWidth,
+                onValueChange = { previewWidth = it },
+                valueRange = 15f..360f,
+                modifier = Modifier.padding(vertical = 3.dp),
+            )
+            Text("相机变焦 ${(cameraZoom * 100).roundToInt()}%")
+            IndustrialSlider(
+                value = cameraZoom,
+                onValueChange = {
+                    cameraZoom = it
+                    viewModel.setCameraZoom(it)
+                },
+                modifier = Modifier.padding(vertical = 3.dp),
+            )
             Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
                 TextButton(onClick = { viewModel.switchLens(false) }) { Text("BACK") }
                 if (viewModel.hasFrontCamera()) TextButton(onClick = { viewModel.switchLens(true) }) { Text("FRONT") }

+ 77 - 0
app/src/main/java/com/joe/camera/webcam/ui/IndustrialSlider.kt

@@ -0,0 +1,77 @@
+package com.joe.camera.webcam.ui
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.gestures.detectDragGestures
+import androidx.compose.foundation.gestures.detectTapGestures
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.onSizeChanged
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import kotlin.math.max
+
+@Composable
+fun IndustrialSlider(
+    value: Float,
+    onValueChange: (Float) -> Unit,
+    modifier: Modifier = Modifier,
+    valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
+    activeColor: Color = Color(0xFF03A9F4),
+) {
+    var widthPx by remember { mutableFloatStateOf(1f) }
+    val density = LocalDensity.current
+    val rangeLength = max(valueRange.endInclusive - valueRange.start, 0.0001f)
+    val progress = ((value - valueRange.start) / rangeLength).coerceIn(0f, 1f)
+    val updateFromX: (Float) -> Unit = { x ->
+        val fraction = (x / widthPx).coerceIn(0f, 1f)
+        onValueChange(valueRange.start + fraction * rangeLength)
+    }
+
+    Canvas(
+        modifier = modifier
+            .fillMaxWidth()
+            .height(30.dp)
+            .onSizeChanged { widthPx = it.width.toFloat().coerceAtLeast(1f) }
+            .pointerInput(widthPx, valueRange) {
+                detectTapGestures { updateFromX(it.x) }
+            }
+            .pointerInput(widthPx, valueRange) {
+                detectDragGestures(
+                    onDragStart = { updateFromX(it.x) },
+                    onDrag = { change, _ ->
+                        change.consume()
+                        updateFromX(change.position.x)
+                    },
+                )
+            },
+    ) {
+        val trackHeight = with(density) { 3.dp.toPx() }
+        val outerRadius = with(density) { 8.dp.toPx() }
+        val innerRadius = with(density) { 3.dp.toPx() }
+        val centerY = size.height / 2f
+        val thumbX = size.width * progress
+        val trackTop = centerY - trackHeight / 2f
+
+        drawRoundRect(
+            color = Color.White.copy(alpha = 0.22f),
+            topLeft = Offset(0f, trackTop),
+            size = Size(size.width, trackHeight),
+            cornerRadius = CornerRadius(trackHeight),
+        )
+        drawRoundRect(
+            color = activeColor,
+            topLeft = Offset(0f, trackTop),
+            size = Size(thumbX, trackHeight),
+            cornerRadius = CornerRadius(trackHeight),
+        )
+        drawCircle(color = activeColor, radius = outerRadius, center = Offset(thumbX, centerY))
+        drawCircle(color = Color(0xFF17171A), radius = innerRadius, center = Offset(thumbX, centerY))
+    }
+}

+ 17 - 5
app/src/main/java/com/joe/camera/webcam/ui/MediaScreens.kt

@@ -21,6 +21,7 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.viewinterop.AndroidView
 import androidx.media3.common.MediaItem as ExoMediaItem
+import androidx.media3.common.MimeTypes
 import androidx.media3.exoplayer.ExoPlayer
 import androidx.media3.ui.PlayerView
 import coil.ImageLoader
@@ -78,11 +79,16 @@ fun ImageViewerScreen(viewModel: MainViewModel, onBack: () -> Unit) {
 fun VideoViewerScreen(viewModel: MainViewModel, onBack: () -> Unit) {
     val item = viewModel.selectedMedia ?: return onBack()
     val context = LocalContext.current
-    val player = remember(item.uri) { ExoPlayer.Builder(context).build().apply { setMediaItem(ExoMediaItem.fromUri(item.uri)); prepare() } }
+    val player = remember(item.uri) {
+        ExoPlayer.Builder(context).build().apply {
+            setMediaItem(ExoMediaItem.Builder().setUri(item.uri).setMimeType(MimeTypes.VIDEO_MP4).build())
+            prepare()
+        }
+    }
     var position by remember { mutableLongStateOf(0L) }
     var duration by remember { mutableLongStateOf(1L) }
     var playing by remember { mutableStateOf(false) }
-    var rotation by remember { mutableFloatStateOf(270f) }
+    var rotation by remember { mutableFloatStateOf(0f) }
     var confirmDelete by remember { mutableStateOf(false) }
     DisposableEffect(player) { onDispose { player.release() } }
     LaunchedEffect(player) { while (true) { position = player.currentPosition.coerceAtLeast(0); duration = player.duration.coerceAtLeast(1); playing = player.isPlaying; delay(100) } }
@@ -92,13 +98,19 @@ fun VideoViewerScreen(viewModel: MainViewModel, onBack: () -> Unit) {
             Column(Modifier.align(Alignment.BottomCenter).fillMaxWidth().background(Color.Black.copy(alpha = 0.35f)).padding(bottom = 20.dp)) {
                 Row(verticalAlignment = Alignment.CenterVertically) {
                     IconButton(onClick = { player.seekTo((player.currentPosition - 34).coerceAtLeast(0)) }) { Icon(Icons.Default.Remove, null) }
-                    Slider(value = position.toFloat().coerceIn(0f, duration.toFloat()), onValueChange = { position = it.toLong(); player.seekTo(position) }, valueRange = 0f..duration.toFloat(), modifier = Modifier.weight(1f))
+                    IndustrialSlider(
+                        value = position.toFloat().coerceIn(0f, duration.toFloat()),
+                        onValueChange = { position = it.toLong(); player.seekTo(position) },
+                        valueRange = 0f..duration.toFloat(),
+                        activeColor = Color.White,
+                        modifier = Modifier.weight(1f),
+                    )
                     IconButton(onClick = { player.seekTo((player.currentPosition + 34).coerceAtMost(duration)) }) { Icon(Icons.Default.Add, null) }
                 }
                 Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly, verticalAlignment = Alignment.CenterVertically) {
-                    IconButton(onClick = { rotation -= 180f }) { Icon(Icons.Default.RotateLeft, null, modifier = Modifier.size(30.dp)) }
+                    IconButton(onClick = { rotation -= 90f }) { Icon(Icons.Default.RotateLeft, null, modifier = Modifier.size(30.dp)) }
                     IconButton(onClick = { if (player.isPlaying) player.pause() else player.play() }) { Icon(if (playing) Icons.Default.Pause else Icons.Default.PlayArrow, null, modifier = Modifier.size(48.dp)) }
-                    IconButton(onClick = { rotation += 180f }) { Icon(Icons.Default.RotateRight, null, modifier = Modifier.size(30.dp)) }
+                    IconButton(onClick = { rotation += 90f }) { Icon(Icons.Default.RotateRight, null, modifier = Modifier.size(30.dp)) }
                 }
             }
         }

+ 14 - 0
app/src/main/res/drawable/ic_launcher_foreground.xml

@@ -0,0 +1,14 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="108dp"
+    android:height="108dp"
+    android:viewportWidth="1739"
+    android:viewportHeight="1024">
+  <group android:scaleX="0.61695653"
+      android:scaleY="0.36324948"
+      android:translateX="342.99045"
+      android:translateY="326.01627">
+    <path
+        android:pathData="M141.8,135.3c-28.4,32.1 1.7,46.6 97,46.9l77.5,0.2 -1.2,65.9 -1.2,65.9 -72.8,-3.9c-65,-3.4 -72.8,-0.8 -72.8,24.5 0,24.2 10.5,29 71.6,32.6l71.6,4.2 2.6,66.6 2.6,66.6H243.7c-78.9,0 -117.4,9.7 -117.4,29.6 0,24.3 248.5,15 258,-9.7 19.5,-50.7 9,-349.6 -13.3,-381.5 -27.8,-39.7 -195.9,-45.6 -229.1,-8.1m323,8.5c-11.3,21.2 -15.6,91.6 -12.9,213.9l4,182.4h238.5l4.1,-100.9c4.8,-118.6 -10.9,-135.3 -120.8,-128.5l-65.7,4.1 -4.3,-66.2 -4.3,-66.2h91.1c93.9,0 113.1,-9.3 98.3,-47.8 -15.1,-39.3 -206.3,-31.6 -228.1,9.3m309.1,0.9c-46,48.9 -47.6,687.3 -1.9,736.4 59.8,64.2 100.2,43.6 174.6,-88.9 80.5,-143.5 85,-146.9 116.1,-90.3 13.4,24.4 44.3,78.1 68.5,119.3 24.2,41.2 41.7,77.3 38.7,80.3 -2.9,2.9 26.2,6.8 64.6,8.5 88,4 88.8,10.4 -28.1,-204.1 -117.9,-216.3 -118,-167.5 0.4,-385.2 104.4,-191.9 107.5,-208.5 38.8,-208.5 -59.3,0 -84.7,31.6 -206.6,257.1 -128.4,237.3 -133.5,236.6 -137.3,-19.8 -1.6,-111.2 -3.2,-210.1 -3.5,-219.7 -0.9,-31.4 -90.9,-20.6 -124.3,14.9m-135.7,290.9v62.4l-66.6,4.3 -66.6,4.3v-61.6c0,-75.6 -0.4,-75.1 73.2,-73.3l60,1.5v62.4m829.5,41.5c-60.5,30.2 -91.9,64.7 -92.6,101.6 -0.2,13.5 -8.3,24.6 -18,24.8 -13.3,0.2 -13.1,3 0.9,11.9 13.2,8.4 17.9,47.1 16.6,136.6 -1,68.7 -1.4,132.8 -0.9,142.4 1.4,26.6 94.2,22.4 104.6,-4.7 4.7,-12.2 8.5,-80.9 8.5,-152.6 0,-138.7 7.8,-156.9 71.2,-166.4 61.2,-9.2 48.7,-122.7 -13.3,-121.1 -12.6,0.3 -47.2,12.7 -77.1,27.6"
+        android:fillColor="#4285f4"/>
+  </group>
+</vector>

+ 5 - 0
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background"/>
+    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+</adaptive-icon>

+ 5 - 0
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml

@@ -0,0 +1,5 @@
+<?xml version="1.0" encoding="utf-8"?>
+<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
+    <background android:drawable="@color/ic_launcher_background"/>
+    <foreground android:drawable="@drawable/ic_launcher_foreground"/>
+</adaptive-icon>

BIN
app/src/main/res/mipmap-hdpi/ic_launcher.webp


BIN
app/src/main/res/mipmap-hdpi/ic_launcher_round.webp


BIN
app/src/main/res/mipmap-mdpi/ic_launcher.webp


BIN
app/src/main/res/mipmap-mdpi/ic_launcher_round.webp


BIN
app/src/main/res/mipmap-xhdpi/ic_launcher.webp


BIN
app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp


BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher.webp


BIN
app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp


BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp


BIN
app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp


+ 4 - 0
app/src/main/res/values/ic_launcher_background.xml

@@ -0,0 +1,4 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+    <color name="ic_launcher_background">#FFFFFF</color>
+</resources>

+ 1 - 0
app/src/test/java/com/joe/camera/webcam/data/MediaRepositoryTest.kt

@@ -8,6 +8,7 @@ class MediaRepositoryTest {
     @Test fun `media constants preserve disguised extensions and real mime types`() {
         assertEquals("image/jpeg", MediaRepository.IMAGE_MIME)
         assertEquals("video/mp4", MediaRepository.VIDEO_MIME)
+        assertEquals("application/octet-stream", MediaRepository.DISGUISED_VIDEO_MIME)
         assertTrue(MediaRepository.RELATIVE_DIR.endsWith("/jf"))
     }