This commit is contained in:
litingting 2025-06-16 11:27:43 +08:00
commit 481452e688
206 changed files with 9281 additions and 0 deletions

15
.gitignore vendored Normal file
View File

@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

1
app/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

86
app/build.gradle.kts Normal file
View File

@ -0,0 +1,86 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.audio.record.screen.test"
compileSdk = 35
defaultConfig {
applicationId = "com.audio.record.screen.test"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures{
viewBinding = true
}
externalNativeBuild {
cmake {
version = "3.22.1"
}
}
// repositories {
// flatDir {
// dirs("libs")
// }
// }
}
dependencies {
implementation("androidx.core:core-ktx:1.13.0")
implementation("androidx.appcompat:appcompat:1.7.0")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.constraintlayout:constraintlayout:2.2.1")
implementation ("androidx.camera:camera-core:1.4.2")
implementation ("androidx.camera:camera-camera2:1.4.2")
implementation ("androidx.camera:camera-lifecycle:1.4.2")
implementation ("androidx.camera:camera-view:1.4.2")
// implementation ("com.arthenica:ffmpeg-kit-full:4.5.1")
// implementation ("com.arthenica:ffmpeg-kit-full:6.0")
// implementation ("com.arthenica:ffmpeg-kit-full-gpl-6.0")
implementation ("androidx.media3:media3-exoplayer:1.6.1")
implementation ("androidx.media3:media3-ui:1.6.1")
implementation("com.github.bumptech.glide:glide:4.16.0")
implementation ("com.github.Jay-Goo:RangeSeekBar:v3.0.0")
implementation("androidx.navigation:navigation-ui-ktx:2.8.9")
implementation("androidx.navigation:navigation-fragment-ktx:2.8.9")
implementation("com.github.bumptech.glide:glide:4.16.0")
implementation(files("libs/jetified-ffmpeg-kit-full-6.0.aar"))
implementation(files("libs/smart-exception-common-0.2.1.jar"))
implementation(files("libs/smart-exception-java-0.2.1.jar"))
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

21
app/proguard-rules.pro vendored Normal file
View File

@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile

View File

@ -0,0 +1,24 @@
package com.audio.record.screen.test
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.audio.record.screen.test", appContext.packageName)
}
}

View File

@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-feature
android:name="android.hardware.camera"
android:required="false" /> <!-- 通知 -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <!-- 前台服务 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" /> <!-- 录音 -->
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <!-- 相机 -->
<uses-permission android:name="android.permission.CAMERA" /> <!-- 悬浮窗 -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<application
android:name=".App"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.RecordScreen"
tools:targetApi="31">
<activity
android:name=".activity.PlayActivity"
android:exported="false" />
<activity
android:name=".activity.ImageViewActivity"
android:exported="false" />
<activity
android:name=".activity.PreviewActivity"
android:exported="false" />
<activity
android:name=".activity.MainActivity1"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".activity.MainActivity"
android:exported="true">
<!-- <intent-filter> -->
<!-- <action android:name="android.intent.action.MAIN" /> -->
<!-- <category android:name="android.intent.category.LAUNCHER" /> -->
<!-- </intent-filter> -->
</activity>
<service
android:name=".service.ScreenRecordService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.audio.record.screen.test.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@ -0,0 +1,17 @@
package com.audio.record.screen.test
import android.app.Application
class App : Application() {
companion object{
val TAG = "-------Screen--------"
lateinit var instanceApp:App
}
override fun onCreate() {
super.onCreate()
instanceApp = this
}
}

View File

@ -0,0 +1,60 @@
package com.audio.record.screen.test.activity
import android.net.Uri
import android.os.Build
import androidx.core.view.isVisible
import com.audio.record.screen.test.base.BaseActivity
import com.audio.record.screen.test.databinding.ActivityImageViewBinding
import com.audio.record.screen.test.tool.Common
import com.bumptech.glide.Glide
/**
* 图片预览页面
*/
class ImageViewActivity : BaseActivity<ActivityImageViewBinding>() {
companion object {
val KEY_URI = "uri_key"
val KEY_name = "name"
}
private var mUri: Uri? = null
private var displayName: String? = null
override fun initBinding(): ActivityImageViewBinding =
ActivityImageViewBinding.inflate(layoutInflater)
override fun getFullColor(): Boolean? = null
override fun onInitPadding(): Boolean = false
override fun onSetViewBefore() {
}
override fun onCreateInit() {
Common.enableImmersiveMode(this)
mUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(KEY_URI, Uri::class.java)
} else {
intent.getParcelableExtra(KEY_URI)
}
displayName = intent.getStringExtra(KEY_name)
initView()
}
private fun initView() {
binding.run {
back.setOnClickListener {
finish()
}
textName.text = displayName
Glide.with(this@ImageViewActivity)
.load(mUri)
.into(imgView)
imgView.setOnClickListener {
layoutName.isVisible = !layoutName.isVisible
}
}
}
}

View File

@ -0,0 +1,286 @@
package com.audio.record.screen.test.activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.hardware.display.DisplayManager
import android.media.MediaRecorder
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.audio.record.screen.test.base.BaseActivity
import com.audio.record.screen.test.databinding.ActivityMainBinding
import com.audio.record.screen.test.service.FloatingWindowBridge
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.ScreenCaptureHelper
import com.audio.record.screen.test.tool.VideoFileHelper
class MainActivity : BaseActivity<ActivityMainBinding>() {
val NOTIFICATION_PERMISSION_REQUEST_CODE = 123
val SCREEN_CAPTURE_REQUEST_CODE = 124
val REQUEST_SCREENSHOT = 125
lateinit var requestPermissionLauncher: ActivityResultLauncher<String>
lateinit var micPermissionLauncher: ActivityResultLauncher<String>
lateinit var cameraPermissionLauncher: ActivityResultLauncher<String>
lateinit var requestStoragePermission: ActivityResultLauncher<String>
lateinit var mediaProjectionManager: MediaProjectionManager
// private var floatingService: ScreenRecordService? = null
private lateinit var tmpVideoUri:Uri
// private val connection = object : ServiceConnection {
// override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
// val binder = service as ScreenRecordService.FloatingBinder
// floatingService = binder.getService()
// binder.setCallback(object : FloatingCallback {
// override fun onFloatingButtonClicked(action: String) {
// // 处理来自悬浮窗的点击
// when (action) {
// "stop_clicked" -> {
//
// }
// }
// }
// })
// }
//
// override fun onServiceDisconnected(name: ComponentName?) {
// floatingService = null
// }
// }
override fun initBinding(): ActivityMainBinding = ActivityMainBinding.inflate(layoutInflater)
override fun getFullColor(): Boolean? = true
override fun onCreateInit() {
initPermissionLauncher()
// checkSyswindow(this@MainActivity)
checkStoragePermissionAndDoSomething()
binding.btn1.setOnClickListener {
requestNotification()
}
binding.btn2.setOnClickListener {
checkStoragePermissionAndDoSomething()
}
binding.btn3.setOnClickListener {
micPermissionLauncher.launch(android.Manifest.permission.RECORD_AUDIO)
}
binding.btn4.setOnClickListener {
checkCamera()
}
binding.btnShowcamera.setOnClickListener {
it.isSelected = !it.isSelected
if(it.isSelected){
FloatingWindowBridge.sendCommand("show")
}else{
FloatingWindowBridge.sendCommand("hide")
}
}
binding.btn7.setOnClickListener {
startActivity(Intent(this,PreviewActivity::class.java))
}
mediaProjectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
binding.btn5.setOnClickListener {
startActivityForResult(mediaProjectionManager.createScreenCaptureIntent(), SCREEN_CAPTURE_REQUEST_CODE)
}
binding.btn6.setOnClickListener {
stopRecording()
}
binding.btnScreenshot.setOnClickListener {
startActivityForResult( mediaProjectionManager.createScreenCaptureIntent(), REQUEST_SCREENSHOT)
}
}
override fun onInitPadding(): Boolean = true
private fun initPermissionLauncher() {
requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
startForegroundService()
Common.showLog("权限授予")
} else {
Common.showLog("权限拒绝")
}
}
micPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
Common.showLog("mic 权限授予")
} else {
Common.showLog("mic 权限拒绝")
}
}
cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
Common.showLog("CAMERA 权限授予")
} else {
Common.showLog("CAMERA 权限拒绝")
}
}
requestStoragePermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// 权限已授予
Common.showLog("已获取存储权限")
// 执行写入文件操作
} else {
Common.showLog( "存储权限被拒绝" )
}
}
}
fun checkStoragePermissionAndDoSomething() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
ContextCompat.checkSelfPermission(this, android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED
) {
requestStoragePermission.launch(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
// Android 10+ 不需要写权限,或者权限已获取
// 执行写入文件操作
Common.showLog("已获取存储权限")
}
}
private fun checkSyswindow(context: Context) {
if (!Settings.canDrawOverlays(context)) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")
)
startActivity(intent)
}
}
private fun requestNotification() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.POST_NOTIFICATIONS
)
!= PackageManager.PERMISSION_GRANTED
) {
requestPermissionLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
} else {
// 权限已授予,可以发送通知
Common.showLog("权限已授予,可以发送通知")
startForegroundService()
}
} else {
// Android 12 及以下,不需要请求权限
Common.showLog("不需要请求权限")
startForegroundService()
}
}
private fun checkCamera() {
if (ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.CAMERA
)
!= PackageManager.PERMISSION_GRANTED
) {
cameraPermissionLauncher.launch(android.Manifest.permission.CAMERA)
} else {
Common.showLog("权限已授予 CAMERA")
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
val mediaProjection = mediaProjectionManager.getMediaProjection(resultCode, data!!)
if (requestCode == SCREEN_CAPTURE_REQUEST_CODE && resultCode == RESULT_OK) {
startRecording(mediaProjection)
}else if (requestCode == REQUEST_SCREENSHOT && resultCode == RESULT_OK && data != null) {
// ScreenCaptureHelper.startScreenCapture(this,mediaProjection)
}
}
private fun startForegroundService() {
// FloatingWindowBridge.startAndBindService(this)
}
fun startRecording(mediaProjection: MediaProjection) {
val screen = VideoFileHelper.getScreenInfo(this@MainActivity)
val width = VideoFileHelper.alignTo16(screen.width)
val height = VideoFileHelper.alignTo16(screen.height)
initRecorder(width, height)
mediaRecorder.start()
val virtualDisplay = mediaProjection.createVirtualDisplay(
"ScreenRecord",
width, height, resources.displayMetrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
mediaRecorder.surface, null, null
)
}
fun stopRecording() {
Common.showLog("-------录屏完成.....")
mediaRecorder.stop()
mediaRecorder.reset()
}
private lateinit var mediaRecorder: MediaRecorder
fun initRecorder(width: Int, height: Int) {
val (videoUri, pfd) = VideoFileHelper.createVideoFile(this, Common.folderName)
tmpVideoUri = videoUri
mediaRecorder = MediaRecorder().apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setVideoSource(MediaRecorder.VideoSource.SURFACE)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setOutputFile(pfd?.fileDescriptor)
setVideoSize(width, height)
setVideoEncoder(MediaRecorder.VideoEncoder.H264)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setVideoEncodingBitRate(8 * 1000 * 1000)
setVideoFrameRate(30)
prepare()
}
}
override fun onDestroy() {
super.onDestroy()
}
}

View File

@ -0,0 +1,229 @@
package com.audio.record.screen.test.activity
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.view.LayoutInflater
import android.widget.ImageView
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.marginBottom
import androidx.fragment.app.Fragment
import androidx.lifecycle.ViewModelProvider
import com.audio.record.screen.test.R
import com.audio.record.screen.test.adapter.ViewPager2Adapter
import com.audio.record.screen.test.base.BaseActivity
import com.audio.record.screen.test.databinding.ActivityMain1Binding
import com.audio.record.screen.test.dialog.DialogPermission
import com.audio.record.screen.test.fragment.MainFragment
import com.audio.record.screen.test.service.ConnectionListener
import com.audio.record.screen.test.service.FloatingCallback
import com.audio.record.screen.test.service.FloatingWindowBridge
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.Extend.setMarginBottom
import com.audio.record.screen.test.tool.Permission
import com.audio.record.screen.test.tool.ScreenCaptureHelper
import com.audio.record.screen.test.viewmodel.MainViewModel
import com.audio.record.screen.test.viewmodel.PreviewViewModel
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayout.OnTabSelectedListener
import com.google.android.material.tabs.TabLayoutMediator
class MainActivity1 : BaseActivity<ActivityMain1Binding>(), ConnectionListener,FloatingCallback {
private lateinit var viewModel: MainViewModel
private var isNotification = false
private var isOverlay = false
private lateinit var requestStoragePermission: ActivityResultLauncher<String>
private lateinit var requestNotificationLauncher: ActivityResultLauncher<String>
private var mPermissionDialog: DialogPermission? = null
//截屏
private lateinit var screenCaptureLauncher: ActivityResultLauncher<Intent>
private lateinit var mediaProjectionManager: MediaProjectionManager
override fun onSetViewBefore() {
super.onSetViewBefore()
// val navigationBarHeight = Common.getNavigationBarHeight(this)
Common.setNavigation(binding.tabLayout)
}
override fun initBinding(): ActivityMain1Binding = ActivityMain1Binding.inflate(layoutInflater)
override fun onInitPadding(): Boolean = false
override fun getFullColor(): Boolean = true
override fun onCreateInit() {
mediaProjectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
viewModel = ViewModelProvider(this)[MainViewModel::class.java]
bingVp()
initLauncher()
firstCheck()
checkStoragePermissionAndDoSomething()
}
private fun initLauncher() {
requestStoragePermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
// 权限已授予
Common.showLog("已获取存储权限")
showPermissionDialog()
// 执行写入文件操作
} else {
Common.showLog("存储权限被拒绝")
}
}
requestNotificationLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
if (isGranted) {
startForegroundService()
Common.showLog("权限授予")
} else {
Common.showLog("权限拒绝")
}
}
// screenCaptureLauncher = registerForActivityResult(
// ActivityResultContracts.StartActivityForResult()
// ) { result ->
// if (result.resultCode == Activity.RESULT_OK) {
// val data: Intent? = result.data
// val mediaProjection =
// mediaProjectionManager.getMediaProjection(result.resultCode, data!!)
// ScreenCaptureHelper.startScreenCapture(this, mediaProjection)
// } else {
// Common.showLog("用户取消了录屏权限授权")
// }
// }
}
fun checkStoragePermissionAndDoSomething() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q &&
ContextCompat.checkSelfPermission(
this,
android.Manifest.permission.WRITE_EXTERNAL_STORAGE
)
!= PackageManager.PERMISSION_GRANTED
) {
requestStoragePermission.launch(android.Manifest.permission.WRITE_EXTERNAL_STORAGE)
} else {
// Android 10+ 不需要写权限,或者权限已获取
// 执行写入文件操作
Common.showLog("已获取存储权限")
showPermissionDialog()
}
}
private fun bingVp() {
val tabIcons =
intArrayOf(R.drawable.tab1_selector, R.drawable.tab2_selector, R.drawable.tab3_selector)
binding.run {
viewPager2.isUserInputEnabled = false
viewPager2.adapter = ViewPager2Adapter(this@MainActivity1)
TabLayoutMediator(tabLayout, viewPager2) { tab, position ->
val customView =
LayoutInflater.from(this@MainActivity1).inflate(R.layout.tab_item, null)
val icon = customView.findViewById<ImageView>(R.id.tab_icon)
if (position in tabIcons.indices) {
icon.setImageResource(tabIcons[position])
}
icon.isSelected = position == 0
tab.customView = customView
}.attach()
tabLayout.addOnTabSelectedListener(object : OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
tab?.customView?.run {
findViewById<ImageView>(R.id.tab_icon).isSelected = true
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) {
tab?.customView?.run {
findViewById<ImageView>(R.id.tab_icon).isSelected = false
}
}
override fun onTabReselected(tab: TabLayout.Tab?) {
}
})
}
}
private fun showPermissionDialog() {
if (isNotification && isOverlay) {
return
}
mPermissionDialog = mPermissionDialog ?: DialogPermission {
when (it) {
DialogPermission.type_ball -> {
intentSysWindow(this@MainActivity1)
}
DialogPermission.type_notification -> {
requestNotificationLauncher.launch(android.Manifest.permission.POST_NOTIFICATIONS)
}
}
}
mPermissionDialog?.show(supportFragmentManager, "")
}
//悬浮窗
private fun intentSysWindow(context: Context) {
val intent = Intent(
Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")
)
startActivity(intent)
}
//检查通知和悬浮窗是否已经enable
private fun firstCheck() {
Permission.checkNotification(this@MainActivity1) {
isNotification = it
if (it) {
startForegroundService()
}
}
Permission.checkOvalApp(this@MainActivity1) {
isOverlay = it
}
}
private fun startForegroundService() {
FloatingWindowBridge.startAndBindService(this@MainActivity1)
FloatingWindowBridge.addListener(this)
}
override fun onStartRecording() {
this@MainActivity1.moveTaskToBack(true)
}
override fun onServiceConnected() {
Common.showLog(" MainActivity1 onServiceConnected registerCallback")
viewModel.updateServiceConnectStatus(true)
FloatingWindowBridge.registerCallback(this)
}
override fun onDestroy() {
super.onDestroy()
FloatingWindowBridge.unregisterCallback(this)
FloatingWindowBridge.removeListener(this)
}
}

View File

@ -0,0 +1,124 @@
package com.audio.record.screen.test.activity
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.view.ViewGroup
import android.widget.ImageButton
import android.widget.ImageView
import android.widget.RelativeLayout
import android.widget.TextView
import androidx.annotation.OptIn
import androidx.core.view.isVisible
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.DefaultTimeBar
import com.audio.record.screen.test.R
import com.audio.record.screen.test.base.BaseActivity
import com.audio.record.screen.test.databinding.ActivityPlayBinding
import com.audio.record.screen.test.tool.Common
/**
* 视频播放页面
*/
class PlayActivity : BaseActivity<ActivityPlayBinding>() {
companion object {
val KEY_URI = "uri_key"
val KEY_name = "name"
}
private var exoPlayer: ExoPlayer? = null
private var mUri: Uri? = null
private var displayName: String? = null
private lateinit var timeBar:DefaultTimeBar
private lateinit var btnPlay:ImageButton
override fun initBinding(): ActivityPlayBinding = ActivityPlayBinding.inflate(layoutInflater)
override fun getFullColor(): Boolean? = null
override fun onInitPadding(): Boolean = false
override fun onSetViewBefore() {
}
override fun onCreateInit() {
Common.enableImmersiveMode(this)
mUri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
intent.getParcelableExtra(KEY_URI, Uri::class.java)
} else {
intent.getParcelableExtra(KEY_URI)
}
displayName = intent.getStringExtra(KEY_name)
initControllerView()
initPlay()
}
private fun initControllerView() {
val controller = binding.playerView.findViewById<ViewGroup>(R.id.exo_controller)
controller.findViewById<ImageView>(R.id.back).setOnClickListener {
finish()
}
controller.findViewById<TextView>(R.id.text_name).text = displayName
btnPlay = controller.findViewById<ImageButton>(R.id.exo_play_pause)
val layoutProgress = controller.findViewById<RelativeLayout>(R.id.layout_progress)
timeBar = controller.findViewById<DefaultTimeBar>(R.id.exo_progress)
}
@OptIn(UnstableApi::class)
private fun initPlay() {
btnPlay.isVisible = false
exoPlayer = ExoPlayer.Builder(this).build()
binding.playerView.run {
player = exoPlayer
controllerShowTimeoutMs = 3000 // 设置控制器显示时间 3 秒后自动隐藏
controllerAutoShow = true // 播放时点击可以自动显示控制器
}
exoPlayer?.run {
val mediaItem = MediaItem.fromUri(mUri!!)
setMediaItem(mediaItem)
prepare()
playWhenReady = true
addListener(object : Player.Listener {
override fun onIsPlayingChanged(isPlaying: Boolean) {
if(isPlaying){
timeBar.isVisible = true
}
}
override fun onPlaybackStateChanged(playbackState: Int) {
if (playbackState == Player.STATE_ENDED) {
// 播放完成
btnPlay.isVisible = true
}
}
})
}
}
override fun onDestroy() {
super.onDestroy()
binding.playerView.player = null
releasePlayer()
}
private fun releasePlayer() {
exoPlayer?.run {
stop() // 可选:停止播放
clearMediaItems()
release()
}
exoPlayer = null
}
}

View File

@ -0,0 +1,183 @@
package com.audio.record.screen.test.activity
import android.graphics.RectF
import android.net.Uri
import android.view.View
import androidx.core.view.isVisible
import androidx.lifecycle.ViewModelProvider
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackParameters
import androidx.media3.exoplayer.ExoPlayer
import androidx.navigation.fragment.NavHostFragment
import com.audio.record.screen.test.App
import com.audio.record.screen.test.R
import com.audio.record.screen.test.base.BaseActivity
import com.audio.record.screen.test.databinding.ActivityPreviewBinding
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.FFmpegKitTool
import com.audio.record.screen.test.viewmodel.PreviewViewModel
import java.io.File
import kotlin.properties.Delegates
class PreviewActivity : BaseActivity<ActivityPreviewBinding>(), View.OnClickListener {
private lateinit var exoPlayer: ExoPlayer
//将当前需要处理的原视频复制到内部存储,方便操作
private lateinit var copyFile: File
private var copyResult by Delegates.notNull<Boolean>()
private lateinit var viewModel: PreviewViewModel
override fun initBinding(): ActivityPreviewBinding =
ActivityPreviewBinding.inflate(layoutInflater)
override fun getFullColor(): Boolean = true
override fun onInitPadding(): Boolean = true
override fun onCreateInit() {
initPlay()
viewModel = ViewModelProvider(this)[PreviewViewModel::class.java]
binding.imPlay.setOnClickListener {
if (!binding.imPlay.isSelected) {
exoPlayer.play()
} else {
exoPlayer.pause()
}
binding.imPlay.isSelected = !binding.imPlay.isSelected
}
val thumbDir = File(App.instanceApp.cacheDir, "thumb").apply {
if (!exists()) {
mkdir()
} else {
Common.deleteAllFilesInDirectory(this.absolutePath)
}
}
// val thumbAdapter = ThumbAdapter(this@PreviewActivity)
// binding.thumbRecycler.apply {
// layoutManager =
// LinearLayoutManager(this@PreviewActivity, RecyclerView.HORIZONTAL, false)
// adapter = thumbAdapter
// }
val open = App.instanceApp.assets.open("record_1748398994963.mp4")
copyFile = File(App.instanceApp.cacheDir, "temp_video.mp4")
//原视频复制到内部存储
FFmpegKitTool.copy(copyFile, open, thumbDir.absolutePath) {
Common.showLog("--------copy success")
copyResult = it
}
initListener()
viewModel.cropRatioText.observe(this) {
if (it.equals("原始")) {
val videoRatio = Common.getVideoRatio(copyFile.absolutePath)
binding.cropView.setAspectRatio(videoRatio)
} else {
val split = it.split(":")
binding.cropView.setAspectRatio(split[0].toFloat() / split[1].toFloat())
}
}
viewModel.saveCrop.observe(this){
val cropFile = File(App.instanceApp.cacheDir, "crop_video_${System.currentTimeMillis()}.mp4")
val videoWH = Common.getVideoWH(copyFile.absolutePath)
val rawCropRect = binding.cropView.getCropRectInVideoCoords(videoWH.first ,
videoWH.second
)
val x = rawCropRect?.left?.toInt() ?: 0
val y = rawCropRect?.top?.toInt() ?: 0
val w = rawCropRect?.width()?.toInt() ?: 0
val h = rawCropRect?.height()?.toInt() ?: 0
Common.showLog("-------videoWH w=${videoWH.first} h=${videoWH.second} x=${x} y = $y w= $w h= $h")
FFmpegKitTool.cropVideo(copyFile.absolutePath,cropFile.absolutePath,x,y,w,h)
}
viewModel.changeSpeed.observe(this){
exoPlayer.playbackParameters = PlaybackParameters(it, it)
}
}
private fun initListener() {
val navHostFragment = supportFragmentManager
.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
val navController = navHostFragment.navController
navController.addOnDestinationChangedListener { controller, destination, arguments ->
Common.showLog("NavControllerListener 当前的目的地: id=${destination.id} name=${destination.displayName} label=${destination.label}")
binding.cropView.isVisible = false
when (destination.id) {
R.id.fragmentCut -> {
viewModel.updateCopyResult(Pair(copyResult, copyFile.absolutePath))
Common.showLog("----fragmentCut")
}
R.id.fragmentCropping -> {
Common.showLog("----fragmentCropping")
initCropView()
}
R.id.fragmentVolume -> {
Common.showLog("----fragmentVolume")
viewModel.updateCopyResult(Pair(copyResult, copyFile.absolutePath))
}
R.id.fragmentSpeed -> {
Common.showLog("----fragmentSpeed")
viewModel.updateCopyResult(Pair(copyResult, copyFile.absolutePath))
}
}
}
}
private fun initCropView() {
binding.cropView.isVisible = true
// 延迟执行以确保布局完成
binding.playerView.post {
val contentFrame: View =
binding.playerView.findViewById(androidx.media3.ui.R.id.exo_content_frame)
if (contentFrame != null) {
// 获取在 cropView 坐标系中的显示区域
val videoRect = RectF()
val contentLoc = IntArray(2)
val cropLoc = IntArray(2)
contentFrame.getLocationOnScreen(contentLoc)
binding.cropView.getLocationOnScreen(cropLoc)
val offsetX = (contentLoc[0] - cropLoc[0]).toFloat()
val offsetY = (contentLoc[1] - cropLoc[1]).toFloat()
videoRect.left = offsetX
videoRect.top = offsetY
videoRect.right = offsetX + contentFrame.width
videoRect.bottom = offsetY + contentFrame.height
// 设置裁剪区域限制边界
binding.cropView.setVideoDisplayBounds(videoRect)
}
}
}
private fun initPlay() {
val uri = Uri.parse("asset:///record_1748398994963.mp4")
exoPlayer = ExoPlayer.Builder(this).build()
binding.playerView.player = exoPlayer
val mediaItem = MediaItem.fromUri(uri)
exoPlayer.setMediaItem(mediaItem)
exoPlayer.prepare()
// exoPlayer.playWhenReady = true
}
override fun onClick(p0: View?) {
}
}

View File

@ -0,0 +1,33 @@
package com.audio.record.screen.test.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.audio.record.screen.test.App
import com.audio.record.screen.test.base.BaseAdapter
import com.audio.record.screen.test.databinding.AdapterCropBinding
import com.audio.record.screen.test.databinding.AdapterThumbBinding
import com.bumptech.glide.Glide
class CropAdapter(context: Context, private var click: (cropRatio:String) -> Unit) :
BaseAdapter<String, AdapterCropBinding>(context) {
override fun getViewBinding(parent: ViewGroup?): AdapterCropBinding {
return AdapterCropBinding.inflate(LayoutInflater.from(parent!!.context), parent, false)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val itemHolder: VHolder<AdapterCropBinding> = holder as VHolder<AdapterCropBinding>
val item = data[position]
with(itemHolder.vb.btn) {
text = item
setOnClickListener {
click.invoke(item)
}
}
}
}

View File

@ -0,0 +1,42 @@
package com.audio.record.screen.test.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.audio.record.screen.test.base.BaseAdapter
import com.audio.record.screen.test.data.ImageGroup
import com.audio.record.screen.test.data.VideoGroup
import com.audio.record.screen.test.databinding.AdapterVideoDateBinding
import com.audio.record.screen.test.tool.Extend.dpToPx
import com.audio.record.screen.test.tool.NoScrollLinearLayoutManager
import com.audio.record.screen.test.view.GridSpacingItemDecoration
class ImageGroupAdapter(context: Context, private var click: (cropRatio: String) -> Unit) :
BaseAdapter<ImageGroup, AdapterVideoDateBinding>(context) {
override fun getViewBinding(parent: ViewGroup?): AdapterVideoDateBinding {
return AdapterVideoDateBinding.inflate(LayoutInflater.from(parent!!.context), parent, false)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val itemHolder: VHolder<AdapterVideoDateBinding> =
holder as VHolder<AdapterVideoDateBinding>
val item = data[position]
itemHolder.vb.run {
tvDate.text = item.date
videoInfoRecycler.run {
adapter = ImageInfoAdapter(mContext, position == 0) {}.apply {
updateData(item.images)
}
layoutManager = GridLayoutManager(mContext, 3)
isNestedScrollingEnabled = false
addItemDecoration(GridSpacingItemDecoration(3, 5.dpToPx(mContext), true))
}
}
}
}

View File

@ -0,0 +1,45 @@
package com.audio.record.screen.test.adapter
import android.content.Context
import android.content.Intent
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.audio.record.screen.test.activity.ImageViewActivity
import com.audio.record.screen.test.activity.PlayActivity
import com.audio.record.screen.test.base.BaseAdapter
import com.audio.record.screen.test.data.ImageInfo
import com.audio.record.screen.test.data.VideoInfo
import com.audio.record.screen.test.databinding.AdapterImageInfoBinding
import com.audio.record.screen.test.databinding.AdapterVideoInfoBinding
import com.audio.record.screen.test.tool.Common
class ImageInfoAdapter(
context: Context,
var showNew: Boolean,
private var click: (cropRatio: String) -> Unit
) :
BaseAdapter<ImageInfo, AdapterImageInfoBinding>(context) {
override fun getViewBinding(parent: ViewGroup?): AdapterImageInfoBinding {
return AdapterImageInfoBinding.inflate(LayoutInflater.from(parent!!.context), parent, false)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val itemHolder: VHolder<AdapterImageInfoBinding> = holder as VHolder<AdapterImageInfoBinding>
Common.showLog("----$position ")
val item = data[position]
itemHolder.vb.run {
tvNew.isVisible = (showNew && position == 0)
image.setImageBitmap(item.thumbnail)
root.setOnClickListener {
mContext.startActivity(Intent(mContext, ImageViewActivity::class.java).apply {
putExtra(ImageViewActivity.KEY_URI, item.uri)
putExtra(ImageViewActivity.KEY_name,item.displayName)
})
}
}
}
}

View File

@ -0,0 +1,26 @@
package com.audio.record.screen.test.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.audio.record.screen.test.App
import com.audio.record.screen.test.base.BaseAdapter
import com.audio.record.screen.test.databinding.AdapterThumbBinding
import com.bumptech.glide.Glide
class ThumbAdapter(context: Context) : BaseAdapter<String, AdapterThumbBinding>(context) {
override fun getViewBinding(parent: ViewGroup?): AdapterThumbBinding {
return AdapterThumbBinding.inflate(LayoutInflater.from(parent!!.context), parent, false)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val itemHolder: VHolder<AdapterThumbBinding> = holder as VHolder<AdapterThumbBinding>
Glide.with(mContext!!).load(data[position]).into(itemHolder.vb.imThumb)
}
}

View File

@ -0,0 +1,30 @@
package com.audio.record.screen.test.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.audio.record.screen.test.App
import com.audio.record.screen.test.base.BaseAdapter
import com.audio.record.screen.test.databinding.AdapterThumbBinding
import com.audio.record.screen.test.databinding.AdapterToolBinding
import com.bumptech.glide.Glide
class ToolAdapter(context: Context, private var click:(type:Int)->Unit) : BaseAdapter<String, AdapterToolBinding>(context) {
override fun getViewBinding(parent: ViewGroup?): AdapterToolBinding {
return AdapterToolBinding.inflate(LayoutInflater.from(parent!!.context), parent, false)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val itemHolder = holder as VHolder<AdapterToolBinding>
val item = data[position]
with(itemHolder.vb.btn) {
text = item
setOnClickListener {
click.invoke(itemHolder.bindingAdapterPosition)
}
}
}
}

View File

@ -0,0 +1,39 @@
package com.audio.record.screen.test.adapter
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.audio.record.screen.test.base.BaseAdapter
import com.audio.record.screen.test.data.VideoGroup
import com.audio.record.screen.test.databinding.AdapterVideoDateBinding
import com.audio.record.screen.test.tool.NoScrollLinearLayoutManager
class VideoGroupAdapter(context: Context, private var click: (cropRatio:String) -> Unit) :
BaseAdapter<VideoGroup, AdapterVideoDateBinding>(context) {
override fun getViewBinding(parent: ViewGroup?): AdapterVideoDateBinding {
return AdapterVideoDateBinding.inflate(LayoutInflater.from(parent!!.context), parent, false)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val itemHolder: VHolder<AdapterVideoDateBinding> = holder as VHolder<AdapterVideoDateBinding>
val item = data[position]
itemHolder.vb.run {
tvDate.text = item.date
videoInfoRecycler.run {
adapter = VideoInfoAdapter(mContext){}.apply {
updateData(item.videos)
}
layoutManager = NoScrollLinearLayoutManager(mContext)
isNestedScrollingEnabled = false
}
}
}
}

View File

@ -0,0 +1,41 @@
package com.audio.record.screen.test.adapter
import android.content.Context
import android.content.Intent
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.audio.record.screen.test.activity.PlayActivity
import com.audio.record.screen.test.base.BaseAdapter
import com.audio.record.screen.test.data.VideoInfo
import com.audio.record.screen.test.databinding.AdapterVideoInfoBinding
import com.audio.record.screen.test.tool.Common
class VideoInfoAdapter(context: Context, private var click: (cropRatio: String) -> Unit) :
BaseAdapter<VideoInfo, AdapterVideoInfoBinding>(context) {
override fun getViewBinding(parent: ViewGroup?): AdapterVideoInfoBinding {
return AdapterVideoInfoBinding.inflate(LayoutInflater.from(parent!!.context), parent, false)
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val itemHolder: VHolder<AdapterVideoInfoBinding> =
holder as VHolder<AdapterVideoInfoBinding>
Common.showLog("----$position ")
val item = data[position]
itemHolder.vb.run {
image.setImageBitmap(item.thumbnail)
tvName.text = item.displayName
tvTime.text = Common.formatDuration(item.duration)
tvSize.text = Common.formatFileSize(item.size)
root.setOnClickListener {
mContext.startActivity(Intent(mContext,PlayActivity::class.java).apply {
putExtra(PlayActivity.KEY_URI, item.uri)
putExtra(PlayActivity.KEY_name,item.displayName)
})
}
}
}
}

View File

@ -0,0 +1,20 @@
package com.audio.record.screen.test.adapter
import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.audio.record.screen.test.fragment.MainFragment
import com.audio.record.screen.test.fragment.RecorderFragment
class ViewPager2Adapter(fragmentActivity: FragmentActivity) :
FragmentStateAdapter(fragmentActivity) {
private val fragments: List<Fragment> = arrayListOf(
MainFragment.newInstance(),
RecorderFragment.newInstance(),
MainFragment.newInstance()
)
override fun getItemCount(): Int = fragments.size
override fun createFragment(position: Int): Fragment = fragments[position]
}

View File

@ -0,0 +1,20 @@
package com.audio.record.screen.test.adapter
import androidx.fragment.app.Fragment
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.audio.record.screen.test.fragment.child.ImageFragment
import com.audio.record.screen.test.fragment.child.VideoFragment
class VpFragmentAdapter(mFragment: Fragment) :
FragmentStateAdapter(mFragment) {
val fragments: List<Fragment> = arrayListOf(
VideoFragment.newInstance(),
ImageFragment.newInstance()
)
override fun getItemCount(): Int = fragments.size
override fun createFragment(position: Int): Fragment = fragments[position]
}

View File

@ -0,0 +1,37 @@
package com.audio.record.screen.test.base
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.WindowCompat
import androidx.viewbinding.ViewBinding
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.Common.setStatusBarTextColor
abstract class BaseActivity<T : ViewBinding> : AppCompatActivity() {
protected lateinit var binding: T
abstract fun initBinding(): T
//true 深色 false 浅色 null 不全屏
abstract fun getFullColor(): Boolean?
open fun onSetViewBefore(){}
abstract fun onCreateInit()
abstract fun onInitPadding():Boolean
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = initBinding()
getFullColor()?.let {
if(onInitPadding()){
binding.root.setPadding(0,Common.dpToPx(40,this),0,0)
}
setStatusBarTextColor(this,it)
}
onSetViewBefore()
setContentView(binding.root)
onCreateInit()
}
}

View File

@ -0,0 +1,64 @@
package com.audio.record.screen.test.base
import android.content.Context
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
abstract class BaseAdapter<K, T : ViewBinding?> : RecyclerView.Adapter<RecyclerView.ViewHolder> {
protected var data: MutableList<K> = ArrayList()
protected lateinit var mContext: Context
var isLoadingAdded = false
protected set
constructor()
constructor(mContext: Context) {
this.mContext = mContext
}
fun addData(data: List<K>?) {
this.data.addAll(data!!)
notifyDataSetChanged()
}
fun updateData(data: List<K>?) {
this.data.clear()
this.data.addAll(data!!)
notifyDataSetChanged()
}
fun addLoadingFooter() {
isLoadingAdded = true
notifyItemInserted(data.size)
}
// Hide loading footer
fun removeLoadingFooter() {
val position = itemCount
if (position >= 0) notifyItemRemoved(position)
isLoadingAdded = false
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val viewBinding = getViewBinding(parent)
return VHolder(viewBinding)
}
protected abstract fun getViewBinding(parent: ViewGroup?): T
override fun getItemViewType(position: Int): Int {
return if (position == data.size && isLoadingAdded) TYPE_FOOTER else TYPE_ITEM
}
override fun getItemCount(): Int {
return data.size + if (isLoadingAdded) 1 else 0
}
class VHolder<V : ViewBinding?>(val vb: V) : RecyclerView.ViewHolder(
vb!!.root
)
companion object {
protected const val TYPE_ITEM = 0
protected const val TYPE_FOOTER = 1
}
}

View File

@ -0,0 +1,24 @@
package com.audio.record.screen.test.base
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.viewbinding.ViewBinding
abstract class BaseFragment<T : ViewBinding> : Fragment() {
protected lateinit var binding: T
abstract fun initBinding(inflater: LayoutInflater, container: ViewGroup?): T
override fun onCreateView(
inflater: LayoutInflater, container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
binding = initBinding(inflater,container)
return binding.root
}
}

View File

@ -0,0 +1,6 @@
package com.audio.record.screen.test.data
data class ImageGroup (
val date: String, // 格式yyyy-MM-dd
val images: List<ImageInfo>
)

View File

@ -0,0 +1,14 @@
package com.audio.record.screen.test.data
import android.graphics.Bitmap
import android.net.Uri
data class ImageInfo(
val uri: Uri,
val displayName: String,
val size: Long,
val dateAdded: Long,
val dateModified: Long,
val thumbnail: Bitmap?
)

View File

@ -0,0 +1,8 @@
package com.audio.record.screen.test.data
data class VideoGroup(
val date: String,
val videos: List<VideoInfo>
)

View File

@ -0,0 +1,15 @@
package com.audio.record.screen.test.data
import android.graphics.Bitmap
import android.net.Uri
data class VideoInfo(
val uri: Uri,
val displayName: String,
val size: Long, // 字节
val duration: Long, // 毫秒
val dateAdded: Long, // 秒
val dateModified: Long, // 秒
val thumbnail: Bitmap? = null // 可选封面图
)

View File

@ -0,0 +1,67 @@
package com.audio.record.screen.test.dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.fragment.app.DialogFragment
import com.audio.record.screen.test.databinding.DialogAudioBinding
import com.audio.record.screen.test.databinding.DialogPermissionBinding
import com.audio.record.screen.test.tool.Permission
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
class DialogAudio(private var mClickType: (type: Int) -> Unit) : DialogFragment() {
private lateinit var vb: DialogAudioBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
vb = DialogAudioBinding.inflate(layoutInflater)
init()
return vb.root
}
override fun onStart() {
super.onStart()
val dialog = dialog
if (dialog != null) {
dialog.setCanceledOnTouchOutside(false)
val window = dialog.window
if (window != null) {
window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
window.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
}
}
}
private fun init() {
vb.run {
close.setOnClickListener {
dismiss()
}
imWithAudio.setOnClickListener {
mClickType.invoke(type_with_audio)
dismiss()
}
imWithoutAudio.setOnClickListener {
mClickType.invoke(type_without_audio)
dismiss()
}
}
}
companion object {
const val type_with_audio = 0
const val type_without_audio = 1
}
}

View File

@ -0,0 +1,84 @@
package com.audio.record.screen.test.dialog
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import com.audio.record.screen.test.databinding.DialogPermissionBinding
import com.audio.record.screen.test.tool.Permission
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
class DialogPermission(private var mClickType: (type: Int) -> Unit) : BottomSheetDialogFragment() {
private lateinit var vb: DialogPermissionBinding
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View? {
vb = DialogPermissionBinding.inflate(layoutInflater)
init()
return vb!!.root
}
override fun onStart() {
super.onStart()
val dialog = dialog
if (dialog != null) {
dialog.setCanceledOnTouchOutside(true)
val window = dialog.window
if (window != null) {
window.setBackgroundDrawable(ColorDrawable(Color.TRANSPARENT))
window.setLayout(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
}
}
}
private fun init() {
vb.run {
enableTvBall.setOnClickListener {
mClickType.invoke(type_ball)
}
enableTvNotification.setOnClickListener {
mClickType.invoke(type_notification)
}
}
}
override fun onResume() {
super.onResume()
refreshUI()
}
private fun refreshUI() {
var ovalay = false
var notification = false
Permission.checkOvalApp(requireContext()) {
vb.layoutBall.isVisible = !it
ovalay = it
if(ovalay&&notification){
dismiss()
}
}
Permission.checkNotification(requireContext()) {
vb.layoutNotification.isVisible = !it
notification = it
if(ovalay&&notification){
dismiss()
}
}
}
companion object {
const val type_ball = 0
const val type_notification = 1
}
}

View File

@ -0,0 +1,76 @@
package com.audio.record.screen.test.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.audio.record.screen.test.R
import com.audio.record.screen.test.adapter.CropAdapter
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.databinding.FragmentCropBinding
import com.audio.record.screen.test.viewmodel.PreviewViewModel
class CropFragment : BaseFragment<FragmentCropBinding>(), View.OnClickListener {
private lateinit var navController: NavController
private lateinit var viewModel: PreviewViewModel
companion object {
@JvmStatic
fun newInstance() =
CropFragment().apply {
arguments = Bundle().apply {
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
}
}
override fun initBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentCropBinding =
FragmentCropBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
navController = findNavController()
viewModel = ViewModelProvider(requireActivity())[PreviewViewModel::class.java]
binding.recycler.run {
val stringList = resources.getStringArray(R.array.crop_text).toList()
adapter = CropAdapter(requireContext()) {
viewModel.updateCropText(it)
}.apply { updateData(stringList) }
layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
}
initClick()
}
private fun initClick() {
binding.imClose.setOnClickListener(this)
binding.imSave.setOnClickListener(this)
}
override fun onClick(v: View?) {
v?.let {
if (it == binding.imSave) {
viewModel.updateClickCropSave(true)
} else if (it == binding.imClose) {
navController.navigateUp()
}
}
}
}

View File

@ -0,0 +1,148 @@
package com.audio.record.screen.test.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.audio.record.screen.test.App
import com.audio.record.screen.test.adapter.ThumbAdapter
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.databinding.FragmentCutBinding
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.FFmpegKitTool
import com.audio.record.screen.test.viewmodel.PreviewViewModel
import com.jaygoo.widget.OnRangeChangedListener
import com.jaygoo.widget.RangeSeekBar
import java.io.File
import kotlin.properties.Delegates
class CutFragment : BaseFragment<FragmentCutBinding>() ,View.OnClickListener{
//毫秒单位
private var leftV by Delegates.notNull<Float>()
private lateinit var viewModel: PreviewViewModel
private var rightV by Delegates.notNull<Float>()
private var param1: String? = null
private var param2: String? = null
private lateinit var copyFilePath: String
private lateinit var navController:NavController
companion object {
@JvmStatic
fun newInstance() =
CutFragment().apply {
arguments = Bundle().apply {
// putString(ARG_PARAM1, param1)
// putString(ARG_PARAM2, param2)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
// param1 = it.getString(ARG_PARAM1)
// param2 = it.getString(ARG_PARAM2)
}
}
override fun initBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentCutBinding =
FragmentCutBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
navController = findNavController()
viewModel = ViewModelProvider(requireActivity())[PreviewViewModel::class.java]
initThumb()
initClick()
}
private fun initThumb(){
val thumbDir = File(App.instanceApp.cacheDir, "thumb")
val thumbAdapter = ThumbAdapter(requireContext())
binding.thumbRecycler.apply {
layoutManager =
LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
adapter = thumbAdapter
}
// val open = App.instanceApp.assets.open("temp_video.mp4")
// tempFile = File(App.instanceApp.cacheDir, "temp_video.mp4")
viewModel.copySuccess.observe(requireActivity()){
if (it.first) {
Common.showLog("-------11111111111111")
copyFilePath = it.second
val allImagePaths = Common.getNaturallySortedThumbFiles(thumbDir.absolutePath)
thumbAdapter.updateData(allImagePaths)
initSeekBar()
}
}
}
private fun initClick() {
binding.imClose.setOnClickListener(this)
binding.imSave.setOnClickListener(this)
}
private fun initSeekBar() {
val durationMs = Common.getVideoDurationMs(copyFilePath)
// val millisToSeconds = Common.millisToSeconds(durationMs)
Common.showLog("----durationMs=${durationMs} ")
binding.rangeSlider.run {
durationMs.toFloat().let {
setRange(0f, it)
leftSeekBar?.setIndicatorText(Common.formatSeconds(0f))
rightSeekBar?.setIndicatorText(Common.formatSeconds(it))
setProgress(0f, it)
}
setOnRangeChangedListener(object : OnRangeChangedListener {
override fun onRangeChanged(
view: RangeSeekBar?,
leftValue: Float,
rightValue: Float,
isFromUser: Boolean
) {
view?.leftSeekBar?.setIndicatorText(Common.formatSeconds(leftValue))
view?.rightSeekBar?.setIndicatorText(Common.formatSeconds(rightValue))
leftV = leftValue
rightV = rightValue
}
override fun onStartTrackingTouch(view: RangeSeekBar?, isLeft: Boolean) {
}
override fun onStopTrackingTouch(view: RangeSeekBar?, isLeft: Boolean) {
}
})
}
}
override fun onClick(p0: View?) {
p0?.let {
if (it == binding.imSave) {
trimVideoFile()
}else if(it == binding.imClose){
navController.navigateUp()
}
}
}
fun trimVideoFile() {
val resultFile = File(requireContext().cacheDir, "test_${System.currentTimeMillis()}.mp4")
val left = leftV.toString()
val right = rightV.toString()
Common.showLog("------left=${left} right=${right}")
// FFmpegKitTool.trimVideo(tempFile.absolutePath, resultFile.absolutePath, left, right)
FFmpegKitTool.cropVideoWithFFmpeg(copyFilePath, resultFile.absolutePath,leftV,rightV)
}
}

View File

@ -0,0 +1,102 @@
package com.audio.record.screen.test.fragment
import android.app.Activity
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView
import androidx.fragment.app.Fragment
import com.audio.record.screen.test.R
import com.audio.record.screen.test.adapter.ViewPager2Adapter
import com.audio.record.screen.test.adapter.VpFragmentAdapter
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.databinding.FragmentMainBinding
import com.audio.record.screen.test.tool.Common
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator
class MainFragment : BaseFragment<FragmentMainBinding>() {
private var param1: String? = null
private var param2: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
// param1 = it.getString(ARG_PARAM1)
// param2 = it.getString(ARG_PARAM2)
}
}
companion object {
@JvmStatic
fun newInstance() =
MainFragment().apply {
arguments = Bundle().apply {
// putString(ARG_PARAM1, param1)
// putString(ARG_PARAM2, param2)
}
}
}
override fun initBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentMainBinding =
FragmentMainBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bingVp()
}
private fun bingVp() {
val tabString =
arrayOf(R.string.Video, R.string.Image)
binding.run {
viewPager2.isUserInputEnabled = false
viewPager2.adapter = VpFragmentAdapter(this@MainFragment)
TabLayoutMediator(tabLayout, viewPager2) { tab, position ->
val customView = LayoutInflater.from(this@MainFragment.context)
.inflate(R.layout.tab_child_item, null)
val tabTv = customView.findViewById<TextView>(R.id.tab_tv)
tabTv.text = getString(tabString[position])
if(position == 0){
tabTv.isSelected = true
tabTv.background = Common.getIcon(R.drawable.tab_child_selected)
}
tab.customView = customView
}.attach()
tabLayout.addOnTabSelectedListener(object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab?) {
tab?.customView?.run {
findViewById<TextView>(R.id.tab_tv).run {
isSelected = true
background = Common.getIcon(R.drawable.tab_child_selected)
}
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) {
tab?.customView?.run {
findViewById<TextView>(R.id.tab_tv).run {
isSelected = false
background = null
}
}
}
override fun onTabReselected(tab: TabLayout.Tab?) {
}
})
}
}
}

View File

@ -0,0 +1,45 @@
package com.audio.record.screen.test.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.navigation.NavController
import androidx.navigation.NavDestination
import androidx.navigation.fragment.NavHostFragment
import com.audio.record.screen.test.R
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.databinding.FragmentRecordBinding
import com.audio.record.screen.test.tool.Common
class RecorderFragment : BaseFragment<FragmentRecordBinding>() {
companion object {
@JvmStatic
fun newInstance() =
RecorderFragment().apply {
}
}
override fun initBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentRecordBinding =
FragmentRecordBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
}
private fun initNavigation(){
val navHostFragment = childFragmentManager
.findFragmentById(R.id.nav_host_view) as NavHostFragment
val navController = navHostFragment.navController
navController.addOnDestinationChangedListener { controller, destination, arguments ->
when (destination.id){
}
}
}
}

View File

@ -0,0 +1,105 @@
package com.audio.record.screen.test.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.audio.record.screen.test.App
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.databinding.FragmentSpeedBinding
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.FFmpegKitTool
import com.audio.record.screen.test.viewmodel.PreviewViewModel
import java.io.File
class SpeedFragment : BaseFragment<FragmentSpeedBinding>(), View.OnClickListener {
private lateinit var viewModel: PreviewViewModel
private lateinit var navController: NavController
private lateinit var copyFilePath: String
private var speed = 1f
companion object {
@JvmStatic
fun newInstance() =
SpeedFragment().apply {
arguments = Bundle().apply {
// putString(ARG_PARAM1, param1)
// putString(ARG_PARAM2, param2)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
// param1 = it.getString(ARG_PARAM1)
// param2 = it.getString(ARG_PARAM2)
}
}
override fun initBinding(
inflater: LayoutInflater,
container: ViewGroup?
): FragmentSpeedBinding =
FragmentSpeedBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(requireActivity())[PreviewViewModel::class.java]
navController = findNavController()
initClick()
viewModel.copySuccess.observe(requireActivity()){
if (it.first) {
Common.showLog("-------11111111111111")
copyFilePath = it.second
}
}
}
private fun initClick() {
binding.imClose.setOnClickListener(this)
binding.imSave.setOnClickListener(this)
binding.seekbar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
speed = 0.5f + (progress / 150f) * 1.5f
binding.progressText.text = "${speed}X"
viewModel.updateSpeed(speed)
Common.showLog("--------progress=${progress} speed=${speed}")
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
}
})
}
override fun onClick(p0: View?) {
p0?.let {
if (it == binding.imSave) {
val volumeFile = File(App.instanceApp.cacheDir, "speed_video_${System.currentTimeMillis()}.mp4")
FFmpegKitTool.buildSpeedCommand(copyFilePath,volumeFile.absolutePath,speed)
} else if (it == binding.imClose) {
navController.navigateUp()
}
}
}
}

View File

@ -0,0 +1,73 @@
package com.audio.record.screen.test.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.navigation.fragment.NavHostFragment.Companion.findNavController
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.audio.record.screen.test.R
import com.audio.record.screen.test.adapter.ToolAdapter
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.databinding.FragmentMainBinding
import com.audio.record.screen.test.databinding.FragmentStartBinding
class StartFragment : BaseFragment<FragmentStartBinding>() {
private var param1: String? = null
private var param2: String? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
// param1 = it.getString(ARG_PARAM1)
// param2 = it.getString(ARG_PARAM2)
}
}
override fun initBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentStartBinding =
FragmentStartBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val navController = findNavController()
binding.toolRecycler.run {
val stringList = resources.getStringArray(R.array.tool_text).toList()
adapter = ToolAdapter(requireContext()){
when(it){
0->{
navController.navigate(R.id.action_to_fragmentCut)
}
1-> {
navController.navigate(R.id.action_to_fragmentCropping)
}
2->{
navController.navigate(R.id.action_to_fragmentVolume)
}
3->{
navController.navigate(R.id.action_to_fragmentSpeed)
}
}
}.apply { updateData(stringList) }
layoutManager = LinearLayoutManager(requireContext(), RecyclerView.HORIZONTAL, false)
}
}
companion object {
@JvmStatic
fun newInstance() =
StartFragment().apply {
arguments = Bundle().apply {
// putString(ARG_PARAM1, param1)
// putString(ARG_PARAM2, param2)
}
}
}
}

View File

@ -0,0 +1,102 @@
package com.audio.record.screen.test.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.SeekBar
import android.widget.SeekBar.OnSeekBarChangeListener
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.audio.record.screen.test.App
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.databinding.FragmentVolumeBinding
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.FFmpegKitTool
import com.audio.record.screen.test.viewmodel.PreviewViewModel
import java.io.File
class VolumeFragment : BaseFragment<FragmentVolumeBinding>(), View.OnClickListener {
private lateinit var viewModel: PreviewViewModel
private lateinit var navController: NavController
private lateinit var copyFilePath: String
companion object {
@JvmStatic
fun newInstance() =
VolumeFragment().apply {
arguments = Bundle().apply {
// putString(ARG_PARAM1, param1)
// putString(ARG_PARAM2, param2)
}
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
// param1 = it.getString(ARG_PARAM1)
// param2 = it.getString(ARG_PARAM2)
}
}
override fun initBinding(
inflater: LayoutInflater,
container: ViewGroup?
): FragmentVolumeBinding =
FragmentVolumeBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewModel = ViewModelProvider(requireActivity())[PreviewViewModel::class.java]
navController = findNavController()
initClick()
viewModel.copySuccess.observe(requireActivity()){
if (it.first) {
Common.showLog("-------11111111111111")
copyFilePath = it.second
}
}
}
private fun initClick() {
binding.imClose.setOnClickListener(this)
binding.imSave.setOnClickListener(this)
binding.seekbar.setOnSeekBarChangeListener(object : OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar?, progress: Int, fromUser: Boolean) {
binding.progressText.text = "${progress}%"
}
override fun onStartTrackingTouch(seekBar: SeekBar?) {
}
override fun onStopTrackingTouch(seekBar: SeekBar?) {
}
})
}
override fun onClick(p0: View?) {
p0?.let {
if (it == binding.imSave) {
val volumeFile = File(App.instanceApp.cacheDir, "volume_video_${System.currentTimeMillis()}.mp4")
val volume = (binding.seekbar.progress.coerceIn(0, 200)) / 100.0f
FFmpegKitTool.setVideVolume(copyFilePath,volumeFile.absolutePath,volume)
} else if (it == binding.imClose) {
navController.navigateUp()
}
}
}
}

View File

@ -0,0 +1,61 @@
package com.audio.record.screen.test.fragment.child
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import com.audio.record.screen.test.adapter.ImageGroupAdapter
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.databinding.FragmentImageBinding
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.VideoFileHelper
class ImageFragment : BaseFragment<FragmentImageBinding>() {
companion object {
@JvmStatic
fun newInstance() =
ImageFragment()
}
override fun initBinding(
inflater: LayoutInflater,
container: ViewGroup?
): FragmentImageBinding =
FragmentImageBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val queryVideoInfoListInFolder =
VideoFileHelper.queryGroupedImagesByDay(requireContext(),Common.imagesFolderDir)
if (queryVideoInfoListInFolder.isEmpty()||queryVideoInfoListInFolder[0].images.isEmpty()) {
binding.run {
layoutEmpty.isVisible = true
}
Common.showLog("IMag isEmpty()")
return
} else {
binding.run {
layoutEmpty.isVisible = false
}
}
val imageGroupAdapter = ImageGroupAdapter(requireContext()) {
}
binding.videoRecycler.run {
adapter = imageGroupAdapter.apply {
updateData(queryVideoInfoListInFolder)
}
layoutManager = LinearLayoutManager(requireContext())
}
}
}

View File

@ -0,0 +1,262 @@
package com.audio.record.screen.test.fragment.child
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.media.projection.MediaProjectionManager
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.audio.record.screen.test.R
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.databinding.FragmentRecordNormalBinding
import com.audio.record.screen.test.dialog.DialogAudio
import com.audio.record.screen.test.dialog.DialogPermission
import com.audio.record.screen.test.service.FloatingCallback
import com.audio.record.screen.test.service.FloatingWindowBridge
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.Permission
import com.audio.record.screen.test.tool.ScreenCaptureHelper
import com.audio.record.screen.test.viewmodel.MainViewModel
import com.audio.record.screen.test.viewmodel.PreviewViewModel
class RecordNormalFragment : BaseFragment<FragmentRecordNormalBinding>(), FloatingCallback {
private lateinit var navController: NavController
private val REQUEST_SCREENSHOT = 125
private lateinit var viewModel: MainViewModel
private lateinit var mediaProjectionManager: MediaProjectionManager
//摄像头
private lateinit var cameraLauncher: ActivityResultLauncher<String>
//截屏
private lateinit var screenCaptureLauncher: ActivityResultLauncher<Intent>
//录制
private lateinit var recorderLauncher: ActivityResultLauncher<Intent>
//录音权限
private lateinit var micPermissionLauncher: ActivityResultLauncher<String>
private var mAudioDialog: DialogAudio? = null
//是否带音频录制
private var isWithAudio = false
override fun initBinding(
inflater: LayoutInflater,
container: ViewGroup?
): FragmentRecordNormalBinding = FragmentRecordNormalBinding.inflate(inflater, container, false)
companion object {
@JvmStatic
fun newInstance() = RecordNormalFragment()
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
navController = findNavController()
mediaProjectionManager =
requireActivity().getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
viewModel = ViewModelProvider(requireActivity())[MainViewModel::class.java]
initClick()
initLauncher()
viewModel.serviceConnectStatus.observe(requireActivity()) {
Common.showLog(" FragmentRecordNormal registerCallback")
FloatingWindowBridge.registerCallback(this)
}
viewModel.ballStatus.observe(requireActivity()) {
Common.showLog(" FragmentRecordNormal 更新ballStatus")
binding.btnFloatingBall.isSelected = it
setBall(it)
}
viewModel.screenshotStatus.observe(requireActivity()) {
Common.showLog(" FragmentRecordNormal 更新screenshotStatus")
binding.btnScreenshot.isSelected = it
setScreenshot(it)
}
viewModel.webcamStatus.observe(requireActivity()) {
Common.showLog(" FragmentRecordNormal 更新webcamStatus")
binding.btnWebcam.isSelected = it
setWebcam(it)
}
}
private fun initLauncher() {
cameraLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_show_camera)
Common.showLog("CAMERA 权限授予")
} else {
// TODO:
Common.showLog("CAMERA 权限拒绝")
}
}
screenCaptureLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val data: Intent? = result.data
FloatingWindowBridge.updateMediaProjection(result.resultCode, data!!)
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_show_screenshot)
} else {
Common.showLog("用户取消了录屏权限授权")
}
}
recorderLauncher =
registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == Activity.RESULT_OK && result.data != null) {
val data = result.data
val resultCode = result.resultCode
FloatingWindowBridge.updateMediaProjection(result.resultCode, data!!)
startCountDown()
} else {
Common.showLog("录屏授权失败 ")
}
}
micPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
Common.showLog("mic 权限授予")
startRecorder()
} else {
Common.showLog("mic 权限拒绝")
}
}
}
private fun initClick() {
binding.run {
btnWebcam.setOnClickListener {
it.isSelected = !it.isSelected
it.isSelected.let { currentStatus ->
viewModel.updateWebcamStatus(currentStatus)
}
}
btnScreenshot.setOnClickListener {
it.isSelected = !it.isSelected
it.isSelected.let { currentStatus ->
viewModel.updateScreenshotStatus(currentStatus)
}
}
btnFloatingBall.setOnClickListener {
it.isSelected = !it.isSelected
it.isSelected.let { currentStatus ->
viewModel.updateBallStatus(currentStatus)
}
}
btnRecorder.setOnClickListener {
showAudioDialog()
}
}
}
private fun setWebcam(boolean: Boolean) {
if (boolean) {
Permission.checkCamera(requireContext()) { openCamera ->
if (openCamera) {
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_show_camera)
} else {
cameraLauncher.launch(android.Manifest.permission.CAMERA)
}
}
} else {
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_hide_camera)
}
}
private fun setScreenshot(boolean: Boolean) {
if (boolean) {
if (FloatingWindowBridge.getMediaProjection() == null) {
val captureIntent = mediaProjectionManager.createScreenCaptureIntent()
screenCaptureLauncher.launch(captureIntent)
} else {
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_show_screenshot)
}
} else {
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_hide_screenshot)
}
}
private fun setBall(boolean: Boolean) {
if (boolean) {
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_show_ball)
} else {
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_hide_ball)
}
}
private fun startRecorder() {
if (FloatingWindowBridge.getMediaProjection() == null) {
val captureIntent = mediaProjectionManager.createScreenCaptureIntent()
recorderLauncher.launch(captureIntent)
} else {
startCountDown()
}
}
private fun showAudioDialog() {
mAudioDialog = mAudioDialog ?: DialogAudio {
when (it) {
DialogAudio.type_with_audio -> {
isWithAudio = true
micPermissionLauncher.launch(android.Manifest.permission.RECORD_AUDIO)
}
DialogAudio.type_without_audio -> {
isWithAudio = false
startRecorder()
}
}
}
mAudioDialog?.show(childFragmentManager, "")
}
private fun startCountDown() {
FloatingWindowBridge.updateAudio(isWithAudio)
val arrayOf = arrayOf("3", "2", "1")
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_show_countdown, arrayOf)
}
override fun onStartRecording() {
navController.navigate(R.id.action_to_recording)
}
override fun onDestroy() {
super.onDestroy()
FloatingWindowBridge.unregisterCallback(this)
}
}

View File

@ -0,0 +1,99 @@
package com.audio.record.screen.test.fragment.child
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModelProvider
import androidx.navigation.NavController
import androidx.navigation.fragment.findNavController
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.databinding.FragmentRecordingBinding
import com.audio.record.screen.test.service.FloatingCallback
import com.audio.record.screen.test.service.FloatingWindowBridge
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.viewmodel.MainViewModel
class RecordingFragment : BaseFragment<FragmentRecordingBinding>(), FloatingCallback {
private lateinit var viewModel: MainViewModel
private lateinit var mFindNavController: NavController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
arguments?.let {
}
}
override fun initBinding(
inflater: LayoutInflater,
container: ViewGroup?
): FragmentRecordingBinding = FragmentRecordingBinding.inflate(inflater, container, false)
companion object {
@JvmStatic
fun newInstance() =
RecordingFragment().apply {
arguments = Bundle().apply {
}
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
mFindNavController = findNavController()
viewModel = ViewModelProvider(requireActivity())[MainViewModel::class.java]
initClick()
viewModel.serviceConnectStatus.observe(requireActivity()) {
Common.showLog(" RecordingFragment registerCallback")
FloatingWindowBridge.registerCallback(this)
}
viewModel.ballStatus.observe(requireActivity()) {
binding.imBall.isSelected = it
}
viewModel.screenshotStatus.observe(requireActivity()) {
binding.imScreenshot.isSelected = it
}
viewModel.webcamStatus.observe(requireActivity()) {
binding.imWebcam.isSelected = it
}
}
private fun initClick() {
binding.run {
imWebcam.setOnClickListener {
it.isSelected = !it.isSelected
viewModel.updateWebcamStatus(it.isSelected)
}
imScreenshot.setOnClickListener {
it.isSelected = !it.isSelected
viewModel.updateScreenshotStatus(it.isSelected)
}
imBall.setOnClickListener {
it.isSelected = !it.isSelected
viewModel.updateBallStatus(it.isSelected)
}
imPauseResume.setOnClickListener {
it.isSelected = !it.isSelected
if (it.isSelected) {
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_pause_record)
} else {
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_resume_record)
}
}
imStop.setOnClickListener {
FloatingWindowBridge.sendCommand(FloatingWindowBridge.COMMEND_stop_record)
mFindNavController.navigateUp()
}
}
}
override fun onUpdateRecordTime(time: String) {
super.onUpdateRecordTime(time)
binding.tvTimer.text = time
}
}

View File

@ -0,0 +1,119 @@
package com.audio.record.screen.test.fragment.child
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.LinearLayoutManager
import com.audio.record.screen.test.activity.PlayActivity
import com.audio.record.screen.test.adapter.VideoGroupAdapter
import com.audio.record.screen.test.base.BaseFragment
import com.audio.record.screen.test.data.VideoInfo
import com.audio.record.screen.test.data.VideoGroup
import com.audio.record.screen.test.databinding.FragmentVideoBinding
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.VideoFileHelper
class VideoFragment : BaseFragment<FragmentVideoBinding>() {
companion object {
@JvmStatic
fun newInstance() =
VideoFragment()
}
override fun initBinding(
inflater: LayoutInflater,
container: ViewGroup?
): FragmentVideoBinding =
FragmentVideoBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val queryVideoInfoListInFolder =
VideoFileHelper.queryVideoInfoListInFolder(requireContext(),Common.videosFolderDir)
if (queryVideoInfoListInFolder.isEmpty()) {
binding.run {
layoutEmpty.isVisible = true
layoutRecent.isVisible = false
}
Common.showLog("queryVideoInfoListInFolder.isEmpty()")
return
} else {
binding.run {
layoutEmpty.isVisible = false
layoutRecent.isVisible = true
}
}
val grouped = VideoFileHelper.groupVideosByDay(queryVideoInfoListInFolder)
val sortedGrouped = grouped.toSortedMap(compareByDescending { it })
val listOf = mutableListOf<VideoGroup>()
sortedGrouped.forEach { (date, videos) ->
listOf.add(VideoGroup(date, videos))
}
initRecent(listOf[0].videos[0])
val removeRecent = removeRecent(listOf)
if (removeRecent.isEmpty()||removeRecent[0].videos.isEmpty()) {
Common.showLog("removeRecent.isEmpty()")
return
}
Common.showLog("--------------------videoRecycler-")
val videoGroupAdapter = VideoGroupAdapter(requireContext()) {
}
binding.videoRecycler.run {
adapter = videoGroupAdapter.apply {
updateData(removeRecent)
}
layoutManager = LinearLayoutManager(requireContext())
}
}
private fun removeRecent(list: MutableList<VideoGroup>): MutableList<VideoGroup> {
val firstItem = list[0]
if (firstItem.videos.isNotEmpty()) {
val updatedFirstItem = firstItem.copy(
videos = firstItem.videos.drop(1) // 去除 videos 的第一个元素
)
list[0] = updatedFirstItem
}
return list
}
private fun initRecent(videoInfo: VideoInfo) {
binding.run {
val duration = Common.formatDuration(videoInfo.duration)
recentImg.setImageBitmap(videoInfo.thumbnail)
recentName.text = videoInfo.displayName
recentTime.text = duration
layoutRecent.setOnClickListener {
requireActivity().startActivity(
Intent(
requireContext(),
PlayActivity::class.java
).apply {
putExtra(PlayActivity.KEY_URI, videoInfo.uri)
putExtra(PlayActivity.KEY_name,videoInfo.displayName)
})
}
}
}
}

View File

@ -0,0 +1,6 @@
package com.audio.record.screen.test.service
interface ConnectionListener {
fun onServiceConnected()
}

View File

@ -0,0 +1,22 @@
package com.audio.record.screen.test.service;
import androidx.lifecycle.Lifecycle;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LifecycleRegistry;
public class CustomLifecycleOwner implements LifecycleOwner {
private final LifecycleRegistry lifecycleRegistry = new LifecycleRegistry(this);
@Override
public Lifecycle getLifecycle() {
return lifecycleRegistry;
}
public void onStart() {
lifecycleRegistry.markState(Lifecycle.State.STARTED);
}
public void onStop() {
lifecycleRegistry.markState(Lifecycle.State.CREATED);
}
}

View File

@ -0,0 +1,10 @@
package com.audio.record.screen.test.service
interface FloatingCallback {
//开始录制
fun onStartRecording(){}
//更新录制时间
fun onUpdateRecordTime(time:String){}
fun onStopRecord(){}
}

View File

@ -0,0 +1,121 @@
package com.audio.record.screen.test.service
import android.annotation.SuppressLint
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.media.projection.MediaProjection
import android.os.IBinder
import androidx.core.content.ContextCompat
import com.audio.record.screen.test.tool.Common
import java.lang.ref.WeakReference
object FloatingWindowBridge {
//activity 向service发送
val COMMEND_show_camera = "show_camera"
val COMMEND_hide_camera = "hide_camera"
val COMMEND_show_screenshot = "show_screenshot"
val COMMEND_hide_screenshot = "hide_screenshot"
val COMMEND_show_ball = "show_ball"
val COMMEND_hide_ball = "hide_ball"
val COMMEND_show_countdown = "show_countdown"
val COMMEND_pause_record = "pause_record"
val COMMEND_resume_record = "resume_record"
val COMMEND_stop_record = "stop_record"
//service 向activity反馈
val CALL_start_recording = "start_recording"
@SuppressLint("StaticFieldLeak")
private var service: ScreenRecordService? = null
private var isBound = false
private val listeners = mutableSetOf<WeakReference<ConnectionListener>>()
private var mBinder: ScreenRecordService.FloatingBinder? = null
private val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
Common.showLog("=======onServiceConnected")
mBinder = binder as? ScreenRecordService.FloatingBinder
service = mBinder?.getService()
listeners.forEach { it.get()?.onServiceConnected() }
}
override fun onServiceDisconnected(name: ComponentName?) {
Common.showLog("=======onServiceDisconnected")
service = null
isBound = false
}
}
fun addListener(listener: ConnectionListener) {
listeners.add(WeakReference(listener))
}
fun removeListener(listener: ConnectionListener) {
listeners.removeAll { it.get() == null || it.get() == listener }
}
fun startAndBindService(context: Context) {
val intent = Intent(context, ScreenRecordService::class.java)
//不受进程存在与否限制
ContextCompat.startForegroundService(context, intent)
if (!isBound) {
//通信
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
isBound = true
}
}
fun updateAudio(audio: Boolean) {
service?.setIsAudio(audio)
}
fun sendCommand(command: String, countdownValues: Array<String>? = null) {
when (command) {
COMMEND_hide_camera -> service?.hideFrontCamera()
COMMEND_show_camera -> service?.showCameraView()
COMMEND_hide_ball -> service?.hideBall()
COMMEND_show_ball -> service?.showBall()
COMMEND_hide_screenshot -> service?.hideScreenshot()
COMMEND_show_screenshot -> service?.showScreenshot()
COMMEND_show_countdown -> countdownValues?.let { service?.showCountDownView(it) }
COMMEND_pause_record -> service?.pauseRecording()
COMMEND_resume_record -> service?.resumeRecording()
COMMEND_stop_record -> service?.stopRecording()
else -> {
}
}
}
fun updateMediaProjection(code: Int, mIntent: Intent) {
service?.createMediaProjection(code, mIntent)
}
fun getMediaProjection() = service?.mIntent
fun registerCallback(callback: FloatingCallback) {
mBinder?.registerCallback(callback)
}
fun unregisterCallback(callback: FloatingCallback) {
mBinder?.unregisterCallback(callback)
}
fun unbind(context: Context) {
if (isBound) {
context.unbindService(connection)
isBound = false
}
}
}

View File

@ -0,0 +1,672 @@
package com.audio.record.screen.test.service
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.ContentUris
import android.content.Context
import android.content.Intent
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.MediaRecorder
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.net.Uri
import android.os.Binder
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.WindowManager
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.FrameLayout
import android.widget.ImageView
import android.widget.RemoteViews
import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.camera.core.CameraSelector
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import com.audio.record.screen.test.R
import com.audio.record.screen.test.activity.PlayActivity
import com.audio.record.screen.test.tool.Common
import com.audio.record.screen.test.tool.DraggableViewHelper
import com.audio.record.screen.test.tool.ScreenCaptureHelper
import com.audio.record.screen.test.tool.VideoFileHelper
import com.audio.record.screen.test.view.CountDownFloatingManager
import java.io.File
import java.lang.ref.WeakReference
class ScreenRecordService : Service() {
private var lifecycleOwner: CustomLifecycleOwner? = null
private var isBallViewAdded = false
private var isScreenshotViewAdded = false
private var isWebcamViewAdded = false
private var isRecordViewAdded = false
//前置摄像头View
private var frontCameraView: View? = null
private lateinit var cameraView: PreviewView
//悬浮球View
private var ballView: View? = null
//截屏View
private var screenshotView: View? = null
//倒计时
private var countdownView: View? = null
private lateinit var countDownHandler: Handler
private var index = 0
//录屏成功View
private var recordView: View? = null
private val windowManager by lazy { getSystemService(WINDOW_SERVICE) as WindowManager }
private lateinit var layoutParamsBall: WindowManager.LayoutParams
private lateinit var layoutParamsScreenshot: WindowManager.LayoutParams
private lateinit var layoutParamsCameraView: WindowManager.LayoutParams
private lateinit var layoutParamsCountDown: WindowManager.LayoutParams
private lateinit var layoutParamsRecordView: WindowManager.LayoutParams
private lateinit var screenWH: Pair<Int, Int>
//截屏
private lateinit var screenCaptureLauncher: ActivityResultLauncher<Intent>
private lateinit var mediaProjectionManager: MediaProjectionManager
private var callbackRef: FloatingCallback? = null
//截屏
var mIntent: Intent? = null
private var mCode: Int = 0
private lateinit var mediaRecorder: MediaRecorder
private lateinit var tmpVideoUri: Uri
private var tmpVideoPfd: ParcelFileDescriptor? = null
private var isWithAudio = false
private val callbacks = mutableListOf<WeakReference<FloatingCallback>>()
//录制时间监听相关
private var recordingStartTime: Long = 0L // 本次录制开始时间
private val mRecorderHandler = Handler(Looper.getMainLooper())
private val updateInterval = 1000L
private var pauseStartTime: Long = 0L // 本次暂停的开始时间
private var totalPausedTime: Long = 0L // 所有暂停段的总时间
//当前是否处于录制暂停
private var isPause = false
private lateinit var virtualDisplay: VirtualDisplay
private var mediaProjection:MediaProjection? = null
private val timeUpdateRunnable = object : Runnable {
override fun run() {
Common.showLog("-------timeUpdateRunnable 更新时间.....")
val currentRecordingTimeInMillis =
System.currentTimeMillis() - recordingStartTime - totalPausedTime
val elapsed = currentRecordingTimeInMillis / 1000
val onRecordingTimeChanged = Common.onRecordingTimeChanged(elapsed)
callbacks.forEach {
it.get()?.onUpdateRecordTime(onRecordingTimeChanged)
}
if (!isPause)
mRecorderHandler.postDelayed(this, updateInterval)
}
}
inner class FloatingBinder : Binder() {
fun getService() = this@ScreenRecordService
fun setCallback(cb: FloatingCallback) {
callbackRef = cb
}
fun registerCallback(callback: FloatingCallback) {
// 避免重复添加
if (callbacks.any { it.get() == callback }) return
callbacks.add(WeakReference(callback))
Common.showLog("=======registerCallback callback=${callback}")
}
fun unregisterCallback(callback: FloatingCallback) {
callbacks.removeAll {
it.get() == null || it.get() == callback
}
Common.showLog("=======unregisterCallback callback=${callback}")
}
}
/**
* 设置当前录制是否带音频
*/
fun setIsAudio(audio: Boolean) {
isWithAudio = audio
Common.showLog("Service---isWithAudio=${isWithAudio}")
}
override fun onCreate() {
super.onCreate()
Common.showLog("Service---onCreate")
lifecycleOwner = CustomLifecycleOwner()
screenWH = Common.getScreenWH(this)
mediaProjectionManager =
getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
}
fun createMediaProjection(code: Int, m: Intent) {
mIntent = m
mCode = code
// mediaProjection = mediaProjectionManager.getMediaProjection(code, m)
}
fun resetIntent(){
mIntent = null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Common.showLog("Service---onStartCommand")
lifecycleOwner?.onStart()
// 初始化 MediaProjection、MediaRecorder 逻辑等
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder {
lifecycleOwner?.onStart()
Common.showLog("Service---onBind")
startForeground(1, createNotification())
return FloatingBinder()
}
//构建通知栏
private fun createNotification(): Notification {
val remoteViews = RemoteViews(packageName, R.layout.custom_notification)
val channelId = "screen_record_channel"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
channelId,
"录屏",
NotificationManager.IMPORTANCE_LOW
)
getSystemService(NotificationManager::class.java).createNotificationChannel(channel)
}
return NotificationCompat.Builder(this, channelId)
.setContentTitle("屏幕录制中")
.setContentText("正在录制屏幕...")
.setCustomContentView(remoteViews)
.setCustomBigContentView(remoteViews)
.setSmallIcon(R.drawable.test)
.build()
}
@SuppressLint("ClickableViewAccessibility")
fun showCameraView() {
if (frontCameraView == null) {
frontCameraView =
LayoutInflater.from(this).inflate(R.layout.floating_front_camera, null)
layoutParamsCameraView = getLayoutParams()
layoutParamsCameraView.gravity = Gravity.TOP or Gravity.START
cameraView = frontCameraView!!.findViewById<PreviewView>(R.id.previewView)
startFrontCamera(cameraView)
DraggableViewHelper.attachToWindow(
frontCameraView!!,
layoutParamsCameraView,
windowManager,
true
)
}
if (!isWebcamViewAdded) {
windowManager.addView(frontCameraView, layoutParamsCameraView)
isWebcamViewAdded = true
}
}
override fun onDestroy() {
super.onDestroy()
hideFrontCamera() // 自动清除
hideBall()
hideScreenshot()
frontCameraView = null
ballView = null
screenshotView = null
callbackRef = null
lifecycleOwner?.onStop()
Common.showLog("Service---onDestroy")
}
fun startFrontCamera(view: PreviewView) {
val cameraProviderFuture = ProcessCameraProvider.getInstance(this)
cameraProviderFuture.addListener({
val cameraProvider = cameraProviderFuture.get()
// 设置预览
val preview = Preview.Builder().build().also {
it.surfaceProvider = view.surfaceProvider
}
// 前置摄像头选择器
val cameraSelector = CameraSelector.Builder()
.requireLensFacing(CameraSelector.LENS_FACING_FRONT)
.build()
try {
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(lifecycleOwner!!, cameraSelector, preview)
} catch (e: Exception) {
Common.showLog("绑定前置摄像头失败==$e")
}
}, ContextCompat.getMainExecutor(this))
}
//显示悬浮球
fun showBall() {
if (ballView == null) {
ballView = LayoutInflater.from(this).inflate(R.layout.floating_ball, null)
layoutParamsBall = getLayoutParams()
val size = Common.dpToPx(36, this)
layoutParamsBall.gravity = Gravity.TOP or Gravity.START
layoutParamsBall.x = screenWH.first - size - 20
layoutParamsBall.y = screenWH.second / 2 - size.div(2) // 居中
DraggableViewHelper.attachToWindow(ballView!!, layoutParamsBall, windowManager, true)
Common.showLog("-------layoutParamsBall.x=${layoutParamsBall.x} layoutParamsBall.y=${layoutParamsBall.y}")
}
if (!isBallViewAdded) {
windowManager.addView(ballView, layoutParamsBall)
isBallViewAdded = true
}
}
//显示截屏
fun showScreenshot() {
if (screenshotView == null) {
screenshotView = LayoutInflater.from(this).inflate(R.layout.floating_screenshot, null)
layoutParamsScreenshot = getLayoutParams()
val size = Common.dpToPx(36, this)
layoutParamsScreenshot.gravity = Gravity.TOP or Gravity.START
layoutParamsScreenshot.x = 20 // 右侧偏移
layoutParamsScreenshot.y = screenWH.second / 2 - size.div(2) // 居中
DraggableViewHelper.attachToWindow(
screenshotView!!,
layoutParamsScreenshot,
windowManager,
true
) {
Common.showLog("--------------callbackRef=${callbackRef}")
// callbackRef?.onFloatingButtonClicked(FloatingWindowBridge.CLICK_screenshot)
mIntent?.let {
mediaProjectionManager.getMediaProjection(mCode, it)?.let {
hideScreenshot()
ScreenCaptureHelper.startScreenCapture(this, it) {
// showScreenshot()
}
}
}
}
}
if (!isScreenshotViewAdded) {
windowManager.addView(screenshotView, layoutParamsScreenshot)
isScreenshotViewAdded = true
}
}
//显示截屏
fun showRecordView() {
if (recordView == null) {
recordView = LayoutInflater.from(this).inflate(R.layout.floating_record_complete, null)
layoutParamsRecordView = getLayoutParams()
layoutParamsRecordView.gravity = Gravity.CENTER
val imClose = recordView!!.findViewById<ImageView>(R.id.close).setOnClickListener {
hideRecordView()
}
recordView!!.findViewById<FrameLayout>(R.id.layout_video).setOnClickListener {
// TODO: 跳到播放页面
val intent = Intent(this, PlayActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
this.startActivity(intent)
}
}
if (!isRecordViewAdded) {
val videoThumbnail = Common.getVideoThumbnail(tmpVideoPfd!!)
Common.showLog("--------------videoThumbnail=${videoThumbnail}")
val thumb = recordView!!.findViewById<ImageView>(R.id.image)
thumb.setImageBitmap(videoThumbnail)
windowManager.addView(recordView, layoutParamsRecordView)
isRecordViewAdded = true
}
}
fun hideFrontCamera() {
frontCameraView?.let {
try {
if (isWebcamViewAdded) {
windowManager.removeView(it)
isWebcamViewAdded = false
}
} catch (_: Exception) {
}
}
// frontCameraView = null
}
//隐藏录制预览View
fun hideRecordView() {
recordView?.let {
try {
if (isRecordViewAdded) {
windowManager.removeView(it)
isRecordViewAdded = false
}
} catch (_: Exception) {
}
}
// frontCameraView = null
}
//隐藏悬浮球
fun hideBall() {
ballView?.let {
try {
windowManager.removeView(it)
isBallViewAdded = false
} catch (_: Exception) {
}
}
// ballView = null
}
//隐藏截屏
fun hideScreenshot() {
screenshotView?.let {
try {
windowManager.removeView(it)
isScreenshotViewAdded = false
} catch (_: Exception) {
}
}
// screenshotView = null
}
//显示倒计时
fun showCountDownView(countdownValues: Array<String>) {
if (countdownView == null) {
countdownView =
LayoutInflater.from(this).inflate(R.layout.floating_view_countdown, null)
layoutParamsCountDown = getLayoutParams()
layoutParamsCountDown.gravity = Gravity.CENTER
}
if (countdownView?.windowToken == null) {
windowManager.addView(countdownView, layoutParamsCountDown)
val tvCountdown = countdownView!!.findViewById<TextView>(R.id.tv_countdown)
index = 0
countDownHandler = Handler(Looper.getMainLooper())
animateNext(countdownValues, tvCountdown) {
//倒计时结束后,开启录制
hideCountDown()
mIntent?.let { intent ->
mediaProjectionManager.getMediaProjection(mCode, intent)
?.let { media->
mediaProjection = media
startRecording()
callbacks.forEach {
it.get()?.onStartRecording()
}
}
}
}
}
}
private fun animateNext(
countdownValues: Array<String>,
tvCountdown: TextView,
onFinish: (() -> Unit)? = null
) {
if (index >= countdownValues.size) {
CountDownFloatingManager.remove(windowManager)
onFinish?.invoke()
return
}
tvCountdown.text = countdownValues[index]
tvCountdown.scaleX = 0f
tvCountdown.scaleY = 0f
tvCountdown.alpha = 0f
tvCountdown.animate()
.scaleX(1f).scaleY(1f)
.alpha(1f)
.setDuration(400)
.setInterpolator(AccelerateDecelerateInterpolator())
.withEndAction {
countDownHandler?.postDelayed({
index++
animateNext(countdownValues, tvCountdown, onFinish)
}, 600)
}
.start()
}
//移除倒计时View
fun hideCountDown() {
countDownHandler.removeCallbacksAndMessages(null)
countdownView?.let {
try {
windowManager.removeView(it)
} catch (e: Exception) {
// view already removed
}
}
countdownView = null
}
/**
* 开启录制视频
*/
fun startRecording() {
Common.showLog("-------录屏中.....")
val fullScreenSize = Common.getFullScreenSize(this)
val width = VideoFileHelper.alignTo16(fullScreenSize.first)
val height = VideoFileHelper.alignTo16(fullScreenSize.second)
initRecorder(width, height)
mediaRecorder.start()
recordingStartTime = System.currentTimeMillis()
totalPausedTime = 0L
mRecorderHandler.post(timeUpdateRunnable)
mediaProjection?.let {
it.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Common.showLog("MediaProjection 被系统或用户停止")
// 这里应该释放 MediaRecorder 和 VirtualDisplay 等
stopRecording()
}
}, Handler(Looper.getMainLooper()))
virtualDisplay = it.createVirtualDisplay(
"ScreenRecord",
width, height, resources.displayMetrics.densityDpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
mediaRecorder.surface, object : VirtualDisplay.Callback() {
override fun onPaused() {
super.onPaused()
Common.showLog("--VirtualDisplay.Callback..onPaused...")
}
override fun onResumed() {
super.onResumed()
Common.showLog("--VirtualDisplay.Callback..onResumed...")
}
override fun onStopped() {
super.onStopped()
Common.showLog("--VirtualDisplay.Callback..onStopped...")
}
}, null
)
}
}
private fun releaseAll() {
if (::mediaRecorder.isInitialized) {
try {
mediaRecorder.stop()
} catch (e: Exception) {
e.printStackTrace()
}
try {
mediaRecorder.reset()
} catch (e: Exception) {
e.printStackTrace()
}
try {
mediaRecorder.release()
} catch (e: Exception) {
e.printStackTrace()
}
}
try {
virtualDisplay?.release()
} catch (e: Exception) {
e.printStackTrace()
}
try {
mediaProjection?.stop()
} catch (e: Exception) {
e.printStackTrace()
}
}
/**
* 暂停录制视频
*/
fun pauseRecording() {
Common.showLog("-------暂停.....")
mediaRecorder.pause()
pauseStartTime = System.currentTimeMillis()
isPause = true
}
/**
* 继续录制视频
*/
fun resumeRecording() {
Common.showLog("------继续.....")
mediaRecorder.resume()
totalPausedTime += System.currentTimeMillis() - pauseStartTime
isPause = false
mRecorderHandler.post(timeUpdateRunnable)
}
private fun initRecorder(width: Int, height: Int) {
val (Uri, pfd) = VideoFileHelper.createVideoFile(this, Common.folderName)
tmpVideoUri = Uri
tmpVideoPfd = pfd
mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
MediaRecorder(applicationContext)
} else {
MediaRecorder()
}.apply {
if (isWithAudio)
setAudioSource(MediaRecorder.AudioSource.MIC)
setVideoSource(MediaRecorder.VideoSource.SURFACE)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setOutputFile(pfd?.fileDescriptor)
setVideoSize(width, height)
setVideoEncoder(MediaRecorder.VideoEncoder.H264)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setVideoEncodingBitRate(8 * 1000 * 1000)
setVideoFrameRate(30)
prepare()
}
}
fun stopRecording() {
Common.showLog("-------录屏完成.....")
mRecorderHandler.removeCallbacks(timeUpdateRunnable)
releaseAll()
VideoFileHelper.markVideoFileCompleted(this, tmpVideoUri)
showRecordView()
callbacks.forEach {
it.get()?.onStopRecord()
}
}
private fun getLayoutParams(): WindowManager.LayoutParams {
return WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
WindowManager.LayoutParams.TYPE_PHONE,
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
PixelFormat.TRANSLUCENT
)
}
}

View File

@ -0,0 +1,16 @@
package com.audio.record.screen.test.service
import android.content.Context
import android.util.AttributeSet
import android.widget.FrameLayout
class TouchThroughLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
override fun onInterceptTouchEvent(ev: android.view.MotionEvent?) = false
override fun onTouchEvent(event: android.view.MotionEvent?) = false
}

View File

@ -0,0 +1,431 @@
package com.audio.record.screen.test.tool
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.content.pm.ActivityInfo
import android.graphics.Bitmap
import android.graphics.Color
import android.media.MediaMetadataRetriever
import android.os.Build
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.util.DisplayMetrics
import android.util.Log
import android.util.TypedValue
import android.view.KeyCharacterMap
import android.view.KeyEvent
import android.view.View
import android.view.ViewConfiguration
import android.view.WindowManager
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
import com.audio.record.screen.test.App
import com.audio.record.screen.test.tool.Extend.setMarginBottom
import java.io.File
object Common {
val folderName = "ScreenRecording_Test"
val videosFolderDir = Environment.DIRECTORY_MOVIES + "/${folderName}"
val imagesFolderDir = Environment.DIRECTORY_PICTURES + "/${folderName}"
fun getIcon(drawId: Int) = ContextCompat.getDrawable(App.instanceApp, drawId)
fun showLog(msg: String) {
Log.d(App.TAG, msg)
}
fun setStatusBarTextColor(activity: Activity, dark: Boolean) {
// val window = activity.window
// val decor = window.decorView
//
// // 设置状态栏图标颜色(深色图标表示浅色背景)
// var flags = decor.systemUiVisibility
// flags = if (dark) {
// flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
// } else {
// flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
// }
// // 保持布局全屏
// flags = flags or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN
// decor.systemUiVisibility = flags
//
// // 去除 TRANSLUCENT_STATUS使用透明背景更现代
// window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
// window.statusBarColor = Color.TRANSPARENT
val window = activity.window
val decor = window.decorView
// 设置状态栏图标颜色:深色图标表示浅色背景
var flags = decor.systemUiVisibility
flags = if (dark) {
flags or View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR
} else {
flags and View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR.inv()
}
// 保持布局延伸到状态栏区域
flags = flags or View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or View.SYSTEM_UI_FLAG_LAYOUT_STABLE
decor.systemUiVisibility = flags
// 去除旧的半透明标志,改为真正透明
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS)
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS)
window.statusBarColor = Color.TRANSPARENT
}
fun dpToPx(dp: Int, context: Context): Int =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP,
dp.toFloat(),
context.resources.displayMetrics,
).toInt()
fun getScreenWH(context: Context): Pair<Int, Int> {
val screenWidth = context.resources.displayMetrics.widthPixels
val screenHeight = context.resources.displayMetrics.heightPixels
return Pair(screenWidth, screenHeight)
}
/**
* 在service中获取设备屏幕的实际像素尺寸包含状态栏导航栏
*/
fun getFullScreenSize(context: Context): Pair<Int, Int> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11 (API 30)+
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val bounds = windowManager.maximumWindowMetrics.bounds
Pair(bounds.width(), bounds.height())
} else {
// Android 10 及以下
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val metrics = DisplayMetrics()
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
Pair(metrics.widthPixels, metrics.heightPixels)
}
}
fun getFull(context: Context): Pair<Int, Int> {
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val realMetrics = DisplayMetrics()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.display?.getRealMetrics(realMetrics)
} else {
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(realMetrics)
}
val realWidth = realMetrics.widthPixels
val realHeight = realMetrics.heightPixels
showLog("getScreenWH realWidth=${realWidth} realHeight=${realHeight}")
return Pair(realWidth, realHeight)
}
fun getNavigationBarHeight(context: Context): Int {
val resources = context.resources
val resourceId = resources.getIdentifier("navigation_bar_height", "dimen", "android")
return if (resourceId > 0 && hasNavigationBar(context)) {
showLog(" fun getNavigationBarHeight = ${resources.getDimensionPixelSize(resourceId)}")
resources.getDimensionPixelSize(resourceId)
} else {
showLog(" fun getNavigationBarHeight = 0")
0
}
}
fun hasNavigationBar(context: Context): Boolean {
val id = context.resources.getIdentifier("config_showNavigationBar", "bool", "android")
return if (id > 0) {
context.resources.getBoolean(id)
} else {
// fallback 方法:判断是否有物理返回键(导航栏可能隐藏)
val hasMenuKey = ViewConfiguration.get(context).hasPermanentMenuKey()
val hasBackKey = KeyCharacterMap.deviceHasKey(KeyEvent.KEYCODE_BACK)
!(hasMenuKey || hasBackKey)
}
}
// 获取状态栏高度兼容所有API
fun getStatusBarHeight(context: Context): Int {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.M -> {
val windowInsets = (context as? Activity)?.window?.decorView?.rootWindowInsets
windowInsets?.stableInsetTop ?: getSystemStatusBarHeight(context)
}
else -> getSystemStatusBarHeight(context)
}
}
private fun getSystemStatusBarHeight(context: Context): Int {
val resources = context.resources
val resourceId = resources.getIdentifier("status_bar_height", "dimen", "android")
return if (resourceId > 0) {
resources.getDimensionPixelSize(resourceId)
} else {
// 默认回退值24dp是常见值
(24 * resources.displayMetrics.density).toInt()
}
}
fun getAllImagePaths(directoryPath: String): List<String> {
val imagePaths = mutableListOf<String>()
val directory = File(directoryPath)
if (directory.exists() && directory.isDirectory) {
directory.walk().forEach { file ->
if (file.isFile && isImageFile(file)) {
imagePaths.add(file.absolutePath)
}
}
}
return imagePaths
}
fun isImageFile(file: File): Boolean {
val imageExtensions = listOf("jpg", "jpeg", "png", "bmp", "gif", "webp")
val extension = file.extension.lowercase()
return extension in imageExtensions
}
fun deleteAllFilesInDirectory(path: String) {
val dir = File(path)
if (dir.exists() && dir.isDirectory) {
dir.listFiles()?.forEach { file ->
if (file.isFile) {
file.delete()
}
}
}
}
/**
* 将缩略图按照 thumb_1thumb_2排序返回
*/
fun getNaturallySortedThumbFiles(path: String): List<String> {
val dir = File(path)
if (!dir.exists() || !dir.isDirectory) return emptyList()
val files = dir.listFiles()
?.filter { it.isFile && it.name.startsWith("thumb_") }
?.sortedBy { extractNumberFromName(it.name) } ?: emptyList()
return files.map {
showLog("-----${it.absolutePath}")
it.absolutePath
}
}
// 提取文件名中的数字部分(如 thumb_5.jpg → 5
private fun extractNumberFromName(name: String): Int {
val regex = Regex("thumb_(\\d+)")
val match = regex.find(name)
return match?.groupValues?.get(1)?.toIntOrNull() ?: Int.MAX_VALUE
}
/**
* 计算视频时长精确到毫秒
*/
fun getVideoDurationMs(path: String): Long {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(path)
val duration =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
retriever.release()
return duration
}
fun millisToSeconds(millis: Long): String {
val seconds = millis / 1000.0
return String.format("%.1f", seconds)
}
/**
* 毫秒数格式化时间格式 000000.0
*/
fun formatSeconds(ms: Float): String {
val totalSeconds = ms / 1000f
val hours = (totalSeconds / 3600).toInt()
val minutes = ((totalSeconds % 3600) / 60).toInt()
val seconds = (totalSeconds % 60).toInt()
val tenth = ((totalSeconds - totalSeconds.toInt()) * 10).toInt() // 小数点后一位
return String.format("%02d:%02d:%02d.%01d", hours, minutes, seconds, tenth)
}
/**
* 毫秒数格式化时间格式 000000
*/
fun formatDuration(durationMs: Long): String {
val totalSeconds = durationMs / 1000
val hours = totalSeconds / 3600
val minutes = (totalSeconds % 3600) / 60
val seconds = totalSeconds % 60
return String.format("%02d:%02d:%02d", hours, minutes, seconds)
}
/**
* bytes格式化
*/
fun formatFileSize(bytes: Long): String {
val kb = 1024
val mb = kb * 1024
val gb = mb * 1024
return when {
bytes >= gb -> "%.2f GB".format(bytes / gb.toFloat())
bytes >= mb -> "%.2f MB".format(bytes / mb.toFloat())
bytes >= kb -> "%.2f KB".format(bytes / kb.toFloat())
else -> "$bytes B"
}
}
fun getVideoRatio(videoPath: String): Float {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(videoPath)
val width = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!
.toInt()
val height = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!
.toInt()
val aspectRatio = width.toFloat() / height
retriever.release()
return aspectRatio
}
fun getVideoWH(videoPath: String): Pair<Int, Int> {
val retriever = MediaMetadataRetriever()
retriever.setDataSource(videoPath)
val width =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)!!.toInt()
val height =
retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)!!.toInt()
retriever.release()
return Pair(width, height)
}
/**
* 返回桌面
*/
fun backHome(mContext: Context){
val intent = Intent(Intent.ACTION_MAIN)
intent.addCategory(Intent.CATEGORY_HOME)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
mContext.startActivity(intent)
}
/**
* 时间转换格式
*/
fun onRecordingTimeChanged(seconds: Long):String {
val minutes = seconds / 60
val sec = seconds % 60
val formatted = String.format("%02d:%02d", minutes, sec)
showLog("Recorder Recording duration: $formatted")
return formatted
// 示例:更新 UI TextView
// binding.timerText.text = formatted
}
/**
* 获取视频封面
*/
fun getVideoThumbnail(pfd: ParcelFileDescriptor): Bitmap? {
val retriever = MediaMetadataRetriever()
try {
retriever.setDataSource(pfd.fileDescriptor)
// 获取第1帧时间戳 0 微秒
return retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
} catch (e: Exception) {
e.printStackTrace()
return null
} finally {
retriever.release()
}
}
fun hideSystemUI(activity: Activity) {
activity.window.decorView.systemUiVisibility =
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY or
View.SYSTEM_UI_FLAG_FULLSCREEN or
View.SYSTEM_UI_FLAG_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or
View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION or
View.SYSTEM_UI_FLAG_LAYOUT_STABLE
}
fun enableImmersiveMode(activity: Activity) {
// 允许内容扩展进系统栏区域
WindowCompat.setDecorFitsSystemWindows(activity.window, false)
val controller = WindowInsetsControllerCompat(activity.window, activity.window.decorView)
// 隐藏状态栏和导航栏
controller.hide(WindowInsetsCompat.Type.systemBars())
// 沉浸式行为:滑动出现系统栏后自动隐藏
controller.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
}
fun enterFullScreen(activity: Activity) {
activity.window.setFlags(
WindowManager.LayoutParams.FLAG_FULLSCREEN,
WindowManager.LayoutParams.FLAG_FULLSCREEN
)
activity.window.decorView.systemUiVisibility = (
View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY
or View.SYSTEM_UI_FLAG_HIDE_NAVIGATION
or View.SYSTEM_UI_FLAG_FULLSCREEN
)
// 如果需要横屏播放:
// activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
}
fun exitFullScreen(activity: Activity) {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN)
activity.window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_VISIBLE
activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
}
fun setNavigation(rootView:View){
ViewCompat.setOnApplyWindowInsetsListener(rootView) { view, insets ->
val navBarInsets = insets.getInsets(WindowInsetsCompat.Type.navigationBars())
val bottomPadding = navBarInsets.bottom
view.setMarginBottom(bottomPadding)
// view.setPadding(
// view.paddingLeft,
// view.paddingTop,
// view.paddingRight,
// bottomPadding // 系统判断好了该不该加 padding
// )
insets
}
}
}

View File

@ -0,0 +1,147 @@
package com.audio.record.screen.test.tool
import android.annotation.SuppressLint
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
object DraggableViewHelper {
/**
* view大小自适应在全屏拖拽更新在windows上的位置
* @param snapToEdge 是否吸边
*/
@SuppressLint("ClickableViewAccessibility")
fun attachToWindow(
view: View,
layoutParams: WindowManager.LayoutParams,
windowManager: WindowManager,
snapToEdge: Boolean = true,
onClick: (() -> Unit)? = null
) {
var dX = 0
var dY = 0
var downTime = 0L
var isDragging = false
val clickThreshold = 200 // 毫秒,判断点击
val moveThreshold = 10 // 像素,判断是否移动
val screenWidth = Common.getScreenWH(view.context).first
val screenHeight = Common.getScreenWH(view.context).second
view.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
dX = (event.rawX - layoutParams.x).toInt()
dY = (event.rawY - layoutParams.y).toInt()
downTime = System.currentTimeMillis()
isDragging = false
true
}
MotionEvent.ACTION_MOVE -> {
val newX = (event.rawX - dX).toInt()
val newY = (event.rawY - dY).toInt()
// 判断是否是有效拖动
if (kotlin.math.abs(newX - layoutParams.x) > moveThreshold ||
kotlin.math.abs(newY - layoutParams.y) > moveThreshold
) {
isDragging = true
}
// 计算新的位置
layoutParams.x = (event.rawX - dX).toInt().coerceIn(0, screenWidth - view.width)
layoutParams.y =
(event.rawY - dY).toInt().coerceIn(0, screenHeight - view.height)
// 更新 window
windowManager.updateViewLayout(view, layoutParams)
true
}
MotionEvent.ACTION_UP -> {
if (!isDragging && System.currentTimeMillis() - downTime < clickThreshold) {
if (onClick != null) {
onClick.invoke()
}
} else if (snapToEdge) {
val middle = screenWidth / 2
layoutParams.x = if (layoutParams.x + view.width / 2 <= middle) {
0
} else {
screenWidth - view.width
}
windowManager.updateViewLayout(view, layoutParams)
}
true
}
else -> false
}
}
}
@SuppressLint("ClickableViewAccessibility")
fun attach(view: View, snapToEdge: Boolean = true) {
var dX = 0f
var dY = 0f
val displayMetrics = view.context.resources.displayMetrics
val screenWidth = displayMetrics.widthPixels
val screenHeight = displayMetrics.heightPixels
Common.showLog("--------screenWidth=$screenWidth screenHeight=${screenHeight}")
view.setOnTouchListener { v, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
dX = event.rawX - v.x
dY = event.rawY - v.y
true
}
MotionEvent.ACTION_MOVE -> {
var newX = event.rawX - dX
var newY = event.rawY - dY
// 限制不出屏幕
newX = newX.coerceIn(0f, (screenWidth - v.width).toFloat())
newY = newY.coerceIn(0f, (screenHeight - v.height).toFloat())
v.x = newX
v.y = newY
true
}
MotionEvent.ACTION_UP -> {
if (snapToEdge) {
// 吸边:判断中线位置
val middle = screenWidth / 2
val targetX = if (v.x + v.width / 2 <= middle) {
0f // 左吸边
} else {
(screenWidth - v.width).toFloat() // 右吸边
}
// 动画吸附
v.animate()
.x(targetX)
.setDuration(200)
.start()
}
true
}
else -> false
}
}
}
}

View File

@ -0,0 +1,54 @@
package com.audio.record.screen.test.tool
import android.content.Context
import android.graphics.Bitmap
import android.media.Image
import android.util.TypedValue
import android.view.View
import android.view.ViewGroup
object Extend {
fun Image.toBitmap(): Bitmap {
val plane = planes[0]
val buffer = plane.buffer
val pixelStride = plane.pixelStride
val rowStride = plane.rowStride
val width = this.width
val height = this.height
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val bitmapBuffer = IntArray(width * height)
var offset = 0
for (i in 0 until height) {
buffer.position(i * rowStride)
for (j in 0 until width) {
// 每次取4字节一个像素 (RGBA格式)
val pixel = buffer.int
bitmapBuffer[offset++] = pixel
}
}
bitmap.setPixels(bitmapBuffer, 0, width, 0, 0, width, height)
return bitmap
}
/**
* @param margin PX
*/
fun View.setMarginBottom(margin: Int) {
val params = layoutParams as? ViewGroup.MarginLayoutParams ?: return
params.bottomMargin = margin
layoutParams = params
}
fun Int.dpToPx(context: Context): Int =
TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_DIP, this.toFloat(),
context.resources.displayMetrics
).toInt()
}

View File

@ -0,0 +1,224 @@
package com.audio.record.screen.test.tool
import android.media.MediaMetadataRetriever
import android.os.Handler
import android.os.Looper
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.ReturnCode
import com.audio.record.screen.test.App
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import java.util.concurrent.TimeUnit
object FFmpegKitTool {
val mainHandler = Handler(Looper.getMainLooper())
fun copy(
tempFile: File,
inputStream: InputStream,
thumbDir: String,
result: (ok: Boolean) -> Unit
) {
FileOutputStream(tempFile).use { outputStream ->
inputStream.copyTo(outputStream)
outputStream.flush()
extractThumbnailsFFmpeg(tempFile.absolutePath, thumbDir, result)
}
}
fun createThumb(inputPath: String, outputDir: String, result: (ok: Boolean) -> Unit) {
// val inputPath = "/storage/emulated/0/Movies/sample.mp4"
// val outputDir = "/storage/emulated/0/Movies/thumbs" // 请确保已创建
val cmd = "-i $inputPath -vf fps=10 $outputDir/thumb_%04d.jpg"
FFmpegKit.executeAsync(cmd) { session ->
val returnCode = session.returnCode
if (ReturnCode.isSuccess(returnCode)) {
Common.showLog("FFmpegKit 缩略图生成成功 ${Thread.currentThread().name}")
result.invoke(true)
} else {
result.invoke(false)
Common.showLog("FFmpegKit 缩略图生成失败:${session.failStackTrace}")
}
}
}
/**
* 截取视频缩略图均分6张图
*/
fun extractThumbnailsFFmpeg(
videoPath: String,
outputDir: String,
result: (ok: Boolean) -> Unit
) {
var count = 0
val durationMs = Common.getVideoDurationMs(videoPath)
val durationSec = durationMs / 1000.0
val step = durationSec / 7 // 均分6帧跳过首尾
Common.showLog("durationSec $durationSec step $step")
for (i in 1..6) {
val timestamp = i * step
val outputPath = "$outputDir/thumb_$i.jpg"
Common.showLog("i = $i timestamp $timestamp ")
val cmd = "-ss $timestamp -i \"$videoPath\" -frames:v 1 -q:v 2 \"$outputPath\""
FFmpegKit.executeAsync(cmd) { session ->
val returnCode = session.returnCode
count++
if (count == 6) {
mainHandler.post {
if (returnCode.isValueSuccess) {
result.invoke(true)
Common.showLog("FFmpeg Frame $i saved to $outputPath")
} else {
result.invoke(false)
Common.showLog("FFmpeg Failed to extract frame $i")
}
}
}
}
}
}
/**
* 剪切视频时长
* @param startTime
*/
fun cropVideoWithFFmpeg(
inputPath: String,
outputPath: String,
startMs: Float,
endMs: Float
) {
val durationMs = endMs - startMs
if (durationMs <= 0) {
println("结束时间必须大于开始时间")
return
}
val formatFloatTime = formatFloatTime(durationMs)
val startTime = formatFloatTime(startMs)
val endTime = formatFloatTime(endMs)
val command =
"-ss $startTime -i \"$inputPath\" -to $formatFloatTime -c:v copy -c:a copy \"$outputPath\""
Common.showLog("--command=${command}")
FFmpegKit.executeAsync(command) { session ->
val returnCode = session.returnCode
if (returnCode.isValueSuccess) {
Common.showLog("裁剪成功: $outputPath")
} else {
Common.showLog("裁剪失败: ${session.failStackTrace}")
}
}
}
// 毫秒转为 ffmpeg 时间格式hh:mm:ss.S
fun formatFloatTime(ms: Float): String {
val totalSeconds = ms / 1000f
val hours = (totalSeconds / 3600).toInt()
val minutes = ((totalSeconds % 3600) / 60).toInt()
val seconds = (totalSeconds % 60).toInt()
val tenths = ((totalSeconds % 1) * 10).toInt() // 保留一位小数
return String.format("%02d:%02d:%02d.%01d", hours, minutes, seconds, tenths)
}
//
// fun trimVideo(inputPath: String, outputPath: String, startTime: String, endTime: String) {
// val command = "-ss $startTime -i $inputPath -to $endTime -c copy $outputPath"
//
// Common.showLog("--command=${command}")
//
// FFmpegKit.executeAsync(command) { session ->
// when {
// ReturnCode.isSuccess(session.returnCode) -> {
// Log.d("FFmpegKit", "剪切成功!输出路径: $outputPath")
// }
//
// session.returnCode.value == ReturnCode.CANCEL -> {
// Log.w("FFmpegKit", "用户取消操作")
// }
//
// else -> {
// Log.e("FFmpegKit", "剪切失败. 错误日志: ${session.output}")
// }
// }
// }
// }
/**
* 裁切视频显示内容尺寸
*/
fun cropVideo(
inputPath: String,
outputPath: String,
cropX: Int,
cropY: Int,
cropWidth: Int,
cropHeight: Int
) {
// val cmd = "-i $inputPath -vf crop=$cropWidth:$cropHeight:$cropX:$cropY -c:a copy $outputPath"
val cmd =
"-i $inputPath -vf crop=$cropWidth:$cropHeight:$cropX:$cropY -c:v mpeg4 -qscale:v 2 -c:a copy $outputPath"
Common.showLog("FFmpeg Crop 比例裁剪 $cmd")
FFmpegKit.executeAsync(cmd) {
if (ReturnCode.isSuccess(it.returnCode)) {
Common.showLog("FFmpeg Crop 比例裁剪成功 $outputPath")
} else {
Common.showLog("FFmpeg Crop 比例裁剪失败: $outputPath ${it.failStackTrace}")
}
}
}
/**
* 调整视频音量
*/
fun setVideVolume(
inputPath: String,
outputPath: String, volumeLevel: Float
) {
// val volumeLevel = 1.5 // 音量倍数(例如 1.5 表示音量加大 50%
val cmd = "-y -i $inputPath -filter:a volume=$volumeLevel -c:v copy $outputPath"
Common.showLog("FFmpeg 音量 $cmd")
FFmpegKit.executeAsync(cmd) { session ->
val returnCode = session.returnCode
if (ReturnCode.isSuccess(returnCode)) {
Common.showLog("FFmpeg 音量调节成功")
} else {
Common.showLog("FFmpeg 音量调节失败: ${session.failStackTrace}")
}
}
}
/**
* 调整视频播放速度
*/
fun buildSpeedCommand(inputPath: String, outputPath: String, speed: Float) {
val videoFilter = "setpts=${1 / speed}*PTS"
val audioFilter = "atempo=$speed"
val cmd = "-y -i $inputPath -filter_complex \"[0:v]$videoFilter[v];[0:a]$audioFilter[a]\" -map \"[v]\" -map \"[a]\" -preset ultrafast $outputPath"
Common.showLog("FFmpeg 播放速度调整 $cmd")
FFmpegKit.executeAsync(cmd) { session ->
if (ReturnCode.isSuccess(session.returnCode)) {
Common.showLog("FFmpeg 播放速度调整成功")
} else {
Common.showLog("FFmpeg 播放速度调整失败: ${session.failStackTrace}")
}
}
}
}

View File

@ -0,0 +1,8 @@
package com.audio.record.screen.test.tool
import android.content.Context
import androidx.recyclerview.widget.LinearLayoutManager
class NoScrollLinearLayoutManager(context: Context) : LinearLayoutManager(context) {
override fun canScrollVertically(): Boolean = false
}

View File

@ -0,0 +1,69 @@
package com.audio.record.screen.test.tool
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import androidx.core.content.ContextCompat
object Permission {
/**
* 检查通知权限
*/
fun checkNotification(mContext:Context,result:(b:Boolean)->Unit){
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
mContext,
android.Manifest.permission.POST_NOTIFICATIONS
)
!= PackageManager.PERMISSION_GRANTED
) {
result.invoke(false)
} else {
// 权限已授予,可以发送通知
Common.showLog("权限已授予,可以发送通知")
result.invoke(true)
}
} else {
// Android 12 及以下,不需要请求权限
Common.showLog("不需要请求权限")
result.invoke(true)
}
}
/**
* 检查悬浮窗权限
*/
fun checkOvalApp(mContext:Context,result:(b:Boolean)->Unit){
if (!Settings.canDrawOverlays(mContext)) {
result.invoke(false)
}else{
result.invoke(true)
}
}
/**
* 检查摄像头权限
*/
fun checkCamera(mContext:Context,result:(b:Boolean)->Unit) {
if (ContextCompat.checkSelfPermission(
mContext,
android.Manifest.permission.CAMERA
)
!= PackageManager.PERMISSION_GRANTED
) {
result.invoke(false)
} else {
result.invoke(true)
Common.showLog("权限已授予 CAMERA")
}
}
}

View File

@ -0,0 +1,229 @@
package com.audio.record.screen.test.tool
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.content.res.Resources
import android.graphics.Bitmap
import android.graphics.PixelFormat
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.Image
import android.media.ImageReader
import android.media.projection.MediaProjection
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.Handler
import android.os.Looper
import android.provider.MediaStore
import android.view.Gravity
import android.view.LayoutInflater
import android.view.WindowManager
import android.view.animation.DecelerateInterpolator
import android.widget.ImageView
import com.audio.record.screen.test.R
import java.io.File
import java.io.FileOutputStream
/**
* 截屏
*/
object ScreenCaptureHelper {
private var virtualDisplay:VirtualDisplay? = null
fun startScreenCapture(
context: Context,
mediaProjection: MediaProjection,
folderName: String = Common.imagesFolderDir,
isOK:()->Unit
) {
val metrics = Resources.getSystem().displayMetrics
val full = Common.getFullScreenSize(context)
val width = full.first
val height = full.second
val density = metrics.densityDpi
Common.showLog("startScreenCapture width=${width}, height=${height} density=${density} Thread=${Thread.currentThread().name}")
val imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
mediaProjection.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
Common.showLog("startScreenCapture MediaProjection 被系统或用户停止")
// 这里应该释放 MediaRecorder 和 VirtualDisplay 等
virtualDisplay?.release()
mediaProjection.stop()
}
}, Handler(Looper.getMainLooper()))
virtualDisplay = mediaProjection.createVirtualDisplay(
"ScreenCapture",
width, height, density,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
imageReader.surface, null, null
)
Handler(Looper.getMainLooper()).postDelayed({
val image = imageReader.acquireLatestImage()
if (image != null) {
val bitmap = imageToBitmap(image)
saveBitmap(context, bitmap, folderName)
isOK.invoke()
showScreenshotPreviewAnimation(context, bitmap,width,height)
image.close()
}
imageReader.close()
virtualDisplay?.release()
mediaProjection.stop()
}, 100)
}
private fun imageToBitmap(image: Image): Bitmap {
val width = image.width
val height = image.height
val plane = image.planes[0]
val buffer = plane.buffer
val pixelStride = plane.pixelStride
val rowStride = plane.rowStride
val rowPadding = rowStride - pixelStride * width
val bitmap = Bitmap.createBitmap(
width + rowPadding / pixelStride,
height,
Bitmap.Config.ARGB_8888
)
bitmap.copyPixelsFromBuffer(buffer)
return Bitmap.createBitmap(bitmap, 0, 0, width, height)
}
private fun saveBitmap(context: Context, bitmap: Bitmap, folderName: String) {
val fileName = "screenshot_${System.currentTimeMillis()}.png"
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+
val contentValues = ContentValues().apply {
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
put(MediaStore.Images.Media.MIME_TYPE, "image/png")
put(
MediaStore.Images.Media.RELATIVE_PATH,
folderName
)
put(MediaStore.Images.Media.IS_PENDING, 1)
}
val contentResolver = context.contentResolver
val uri = contentResolver.insert(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
contentValues
)
uri?.let {
contentResolver.openOutputStream(it)?.use { stream ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
}
contentValues.clear()
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
contentResolver.update(uri, contentValues, null, null)
}
} else {
// Android 7-9
val picturesDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES)
val folder = File(folderName)
if (!folder.exists()) folder.mkdirs()
val file = File(folder, fileName)
FileOutputStream(file).use { fos ->
bitmap.compress(Bitmap.CompressFormat.PNG, 100, fos)
}
// 通知媒体库刷新
context.sendBroadcast(Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(file)))
}
}
/**
* 截屏的动画效果
*/
fun showScreenshotPreviewAnimation(context: Context, screenshotBitmap: Bitmap,screenWidth:Int,screenHeight:Int) {
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val inflate = LayoutInflater.from(context).inflate(R.layout.floating_screenshot_anim, null)
val imageView = inflate.findViewById<ImageView>(R.id.image)
imageView.setImageBitmap(screenshotBitmap)
val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
WindowManager.LayoutParams.TYPE_PHONE,
WindowManager.LayoutParams.FLAG_NOT_TOUCH_MODAL or
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
PixelFormat.TRANSLUCENT
).apply {
gravity = Gravity.TOP or Gravity.START
}
imageView.post {
Common.showLog("实际宽高 width=${imageView.width}, height=${imageView.height}")
}
Common.showLog("layoutParams layoutParams x=${layoutParams.x}, y=${layoutParams.y}")
windowManager.addView(inflate, layoutParams)
val scale = 0.3f
val targetWidth = (screenWidth * scale).toInt()
val targetHeight = (screenHeight * scale).toInt()
val targetX = screenWidth - targetWidth - 20
val navigationBarHeight = Common.getNavigationBarHeight(context)
val statusBarHeight = Common.getStatusBarHeight(context)
Common.showLog("navigationBarHeight=${navigationBarHeight}, statusBarHeight=${statusBarHeight} ")
val targetY = screenHeight - targetHeight - navigationBarHeight - statusBarHeight - 20
Common.showLog("宽高 targetWidth=${targetWidth}, targetHeight=${targetHeight} targetX = ${targetX} targetY = ${targetY}")
inflate.animate()
.scaleX(scale)
.scaleY(scale)
.setDuration(600)
.setInterpolator(DecelerateInterpolator())
.withEndAction {
// 清除动画偏移
inflate.scaleX = 1f
inflate.scaleY = 1f
inflate.translationX = 0f
inflate.translationY = 0f
// 更新 layoutParams 为最终目标位置与尺寸
layoutParams.width = targetWidth
layoutParams.height = targetHeight
layoutParams.x = targetX
layoutParams.y = targetY
Common.showLog("动画结束 targetWidth=${targetWidth}, targetHeight=${targetHeight} targetX = ${targetX} targetY = ${targetY}")
windowManager.updateViewLayout(inflate, layoutParams)
// 5秒后自动移除
inflate.postDelayed({
try {
windowManager.removeView(inflate)
} catch (e: Exception) {
e.printStackTrace()
}
}, 3000)
}
.start()
inflate.setOnClickListener {
Common.showLog("-----点击小截图")
}
}
}

View File

@ -0,0 +1,368 @@
package com.audio.record.screen.test.tool
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.ParcelFileDescriptor
import android.provider.MediaStore
import android.util.DisplayMetrics
import android.util.Size
import android.view.WindowManager
import androidx.core.content.FileProvider
import com.audio.record.screen.test.data.ImageGroup
import com.audio.record.screen.test.data.ImageInfo
import com.audio.record.screen.test.data.VideoInfo
import java.io.File
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
object VideoFileHelper {
data class ScreenInfo(val width: Int, val height: Int, val dpi: Int)
fun getScreenInfo(context: Context): ScreenInfo {
val metrics = DisplayMetrics()
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
context.display?.getRealMetrics(metrics)
} else {
@Suppress("DEPRECATION")
windowManager.defaultDisplay.getRealMetrics(metrics)
}
return ScreenInfo(metrics.widthPixels, metrics.heightPixels, metrics.densityDpi)
}
fun alignTo16(value: Int): Int = (value + 15) / 16 * 16
/**
* 创建一个用于 MediaRecorder 或其他输出的视频文件路径
* @param context 上下文
* @param folderName 你想要创建的文件夹名称位于 Movies/
* @param displayName 文件名不含扩展名
* @return 文件的 Uri FileDescriptor可选
*/
fun createVideoFile(
context: Context,
folderName: String,
displayName: String = "${System.currentTimeMillis()}"
): Pair<Uri, ParcelFileDescriptor?> {
val resolver = context.contentResolver
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues = ContentValues().apply {
put(MediaStore.Video.Media.DISPLAY_NAME, "$displayName.mp4")
put(MediaStore.Video.Media.MIME_TYPE, "video/mp4")
put(
MediaStore.Video.Media.RELATIVE_PATH,
Environment.DIRECTORY_MOVIES + "/$folderName"
)
put(MediaStore.Video.Media.IS_PENDING, 1)
}
val videoUri = resolver.insert(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
contentValues
) ?: throw IOException("无法创建视频文件 Uri")
val pfd = try {
resolver.openFileDescriptor(videoUri, "w")
} catch (e: Exception) {
e.printStackTrace()
null
}
return Pair(videoUri, pfd)
} else {
// Android 9及以下使用旧方式注意申请权限
val moviesDir =
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES)
val folder = File(moviesDir, folderName).apply { if (!exists()) mkdirs() }
val file = File(folder, "$displayName.mp4").apply { if (!exists()) createNewFile() }
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider", // 记得配置 provider
file
)
val pfd = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_WRITE_ONLY)
return Pair(uri, pfd)
}
}
fun markVideoFileCompleted(context: Context, videoUri: Uri) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val resolver = context.contentResolver
val contentValues = ContentValues().apply {
put(MediaStore.Video.Media.IS_PENDING, 0)
}
resolver.update(videoUri, contentValues, null, null)
}
}
/**
* 遍历指定目录folderName下的所有视频
*/
fun queryVideoInfoListInFolder(context: Context, folderName: String): List<VideoInfo> {
Common.showLog("------folderName=${folderName}")
val videoInfoList = mutableListOf<VideoInfo>()
val resolver = context.contentResolver
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10+ 用 MediaStore 查询
val projection = arrayOf(
MediaStore.Video.Media._ID,
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.SIZE,
MediaStore.Video.Media.DURATION,
MediaStore.Video.Media.DATE_ADDED,
MediaStore.Video.Media.DATE_MODIFIED
)
val selection = "${MediaStore.Video.Media.RELATIVE_PATH} = ?"
val selectionArgs = arrayOf("$folderName/")
val sortOrder = "${MediaStore.Video.Media.DATE_ADDED} DESC"
resolver.query(
MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media._ID)
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.SIZE)
val durationColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DURATION)
val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_ADDED)
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.Video.Media.DATE_MODIFIED)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val uri = ContentUris.withAppendedId(MediaStore.Video.Media.EXTERNAL_CONTENT_URI, id)
val displayName = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
val duration = cursor.getLong(durationColumn)
val dateAdded = cursor.getLong(dateAddedColumn)
val dateModified = cursor.getLong(dateModifiedColumn)
// 获取封面图 (Android Q+ 推荐用 loadThumbnail)
val thumbnail = try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
resolver.loadThumbnail(uri, Size(1280,720), null)
} else {
null
}
} catch (e: Exception) {
e.printStackTrace()
null
}
val videoInfo = VideoInfo(
uri = uri,
displayName = displayName,
size = size,
duration = duration,
dateAdded = dateAdded,
dateModified = dateModified,
thumbnail = thumbnail
)
videoInfoList.add(videoInfo)
}
}
} else {
// Android 9 及以下File API 查询
val folderPath = Environment.getExternalStoragePublicDirectory(
folderName
).absolutePath
val folder = File(folderPath)
if (folder.exists() && folder.isDirectory) {
val videoFiles = folder.listFiles { file ->
file.isFile && file.extension.equals("mp4", ignoreCase = true)
}?.toList() ?: emptyList()
videoFiles.forEach { file ->
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
// 获取视频基本信息(只能通过 MediaMetadataRetriever 获取部分信息)
val retriever = MediaMetadataRetriever()
retriever.setDataSource(context, uri)
val duration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLongOrNull() ?: 0L
val thumbnail = try {
retriever.getFrameAtTime(0, MediaMetadataRetriever.OPTION_CLOSEST_SYNC)
} catch (e: Exception) {
e.printStackTrace()
null
}
retriever.release()
val videoInfo = VideoInfo(
uri = uri,
displayName = file.name,
size = file.length(),
duration = duration,
dateAdded = file.lastModified() / 1000,
dateModified = file.lastModified() / 1000,
thumbnail = thumbnail
)
videoInfoList.add(videoInfo)
}
}
}
return videoInfoList
}
/**
* 遍历指定目录folderName下的所有图片
*/
private fun queryImagesInFolder(context: Context, folderName: String): List<ImageInfo> {
Common.showLog("-----图片-folderName=${folderName}")
val imageInfoList = mutableListOf<ImageInfo>()
val resolver = context.contentResolver
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val projection = arrayOf(
MediaStore.Images.Media._ID,
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE,
MediaStore.Images.Media.DATE_ADDED,
MediaStore.Images.Media.DATE_MODIFIED
)
val selection = "${MediaStore.Images.Media.RELATIVE_PATH} = ?"
val selectionArgs = arrayOf("$folderName/")
val sortOrder = "${MediaStore.Images.Media.DATE_ADDED} DESC"
resolver.query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
projection,
selection,
selectionArgs,
sortOrder
)?.use { cursor ->
val idColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)
val nameColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.SIZE)
val dateAddedColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_ADDED)
val dateModifiedColumn = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATE_MODIFIED)
while (cursor.moveToNext()) {
val id = cursor.getLong(idColumn)
val uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id)
val displayName = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
val dateAdded = cursor.getLong(dateAddedColumn)
val dateModified = cursor.getLong(dateModifiedColumn)
val thumbnail = try {
resolver.loadThumbnail(uri, Size(320, 320), null)
} catch (e: Exception) {
null
}
Common.showLog("-----图片-displayName=${displayName}")
val info = ImageInfo(
uri = uri,
displayName = displayName,
size = size,
dateAdded = dateAdded,
dateModified = dateModified,
thumbnail = thumbnail
)
imageInfoList.add(info)
}
}
} else {
// Android 7-9 (API 24~28),用 File API
val folder = File(folderName)
if (folder.exists() && folder.isDirectory) {
val files = folder.listFiles { file ->
file.isFile && (file.extension.equals("jpg", true)
|| file.extension.equals("png", true)
|| file.extension.equals("jpeg", true))
}?.toList() ?: emptyList()
files.forEach { file ->
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val bitmap = try {
BitmapFactory.decodeFile(file.absolutePath)
} catch (e: Exception) {
null
}
val info = ImageInfo(
uri = uri,
displayName = file.name,
size = file.length(),
dateAdded = file.lastModified() / 1000,
dateModified = file.lastModified() / 1000,
thumbnail = bitmap
)
imageInfoList.add(info)
}
}
}
return imageInfoList
}
fun queryGroupedImagesByDay(context: Context, folderName: String): List<ImageGroup> {
val allImages = queryImagesInFolder(context, folderName)
Common.showLog("-----图片-allImages=${allImages.size}")
// 分组处理:按 dateAdded 转换为 yyyy-MM-dd
val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
return allImages
.groupBy { image ->
val date = Date(image.dateAdded * 1000) // 秒 → 毫秒
dateFormat.format(date)
}
.map { (date, images) ->
ImageGroup(date, images)
}
.sortedByDescending { it.date } // 按日期倒序排
}
fun groupVideosByDay(videoList: List<VideoInfo>): Map<String, List<VideoInfo>> {
val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault())
return videoList.groupBy { video ->
val timestampMillis = video.dateAdded * 1000 // 秒转毫秒
sdf.format(Date(timestampMillis)) // 格式化成日期字符串
}
}
}

View File

@ -0,0 +1,276 @@
package com.audio.record.screen.test.view;
import android.content.Context;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.RectF;
import android.os.Build;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import androidx.annotation.Nullable;
public class AspectRatioCropView extends View {
private RectF cropRect = new RectF();
private RectF lastRect = new RectF();
private RectF videoBounds = null;
private Paint borderPaint;
private Paint shadePaint;
private float aspectRatio = 4f / 5f;
private int minSize = 100;
private boolean isDragging = false;
private float lastTouchX, lastTouchY;
private int dragHandle = 0;
public AspectRatioCropView(Context context) {
super(context);
init();
}
public AspectRatioCropView(Context context, AttributeSet attrs) {
super(context, attrs);
init();
}
private void init() {
borderPaint = new Paint();
borderPaint.setColor(Color.WHITE);
borderPaint.setStyle(Paint.Style.STROKE);
borderPaint.setStrokeWidth(4f);
shadePaint = new Paint();
shadePaint.setColor(0x33000000);
}
public void setVideoDisplayBounds(RectF bounds) {
this.videoBounds = bounds;
updateCropRect(bounds.centerX(), bounds.centerY(),
Math.min(bounds.width(), bounds.height()) * 0.5f);
}
private void updateCropRect(float centerX, float centerY, float size) {
float width, height;
if (aspectRatio >= 1) {
width = size;
height = width / aspectRatio;
} else {
height = size;
width = height * aspectRatio;
}
cropRect.left = centerX - width / 2;
cropRect.top = centerY - height / 2;
cropRect.right = centerX + width / 2;
cropRect.bottom = centerY + height / 2;
enforceBounds();
invalidate();
}
// private void enforceBounds() {
// if (videoBounds == null) return;
//
// float dx = 0, dy = 0;
//
// if (cropRect.left < videoBounds.left) dx = videoBounds.left - cropRect.left;
// if (cropRect.top < videoBounds.top) dy = videoBounds.top - cropRect.top;
// if (cropRect.right > videoBounds.right) dx = videoBounds.right - cropRect.right;
// if (cropRect.bottom > videoBounds.bottom) dy = videoBounds.bottom - cropRect.bottom;
//
// cropRect.offset(dx, dy);
// }
private void enforceBounds() {
if (videoBounds == null) return;
float dx = 0, dy = 0;
// 缩小裁剪框使其适应边界
if (cropRect.width() > videoBounds.width()) {
float newWidth = videoBounds.width();
float newHeight = newWidth / aspectRatio;
cropRect.left = videoBounds.left;
cropRect.right = videoBounds.right;
cropRect.top = (videoBounds.top + videoBounds.bottom - newHeight) / 2;
cropRect.bottom = cropRect.top + newHeight;
} else if (cropRect.height() > videoBounds.height()) {
float newHeight = videoBounds.height();
float newWidth = newHeight * aspectRatio;
cropRect.top = videoBounds.top;
cropRect.bottom = videoBounds.bottom;
cropRect.left = (videoBounds.left + videoBounds.right - newWidth) / 2;
cropRect.right = cropRect.left + newWidth;
}
// 位置微调避免轻微偏移
if (cropRect.left < videoBounds.left) dx = videoBounds.left - cropRect.left;
if (cropRect.top < videoBounds.top) dy = videoBounds.top - cropRect.top;
if (cropRect.right > videoBounds.right) dx = videoBounds.right - cropRect.right;
if (cropRect.bottom > videoBounds.bottom) dy = videoBounds.bottom - cropRect.bottom;
cropRect.offset(dx, dy);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
int saveCount = canvas.save();
// 阴影层
canvas.drawRect(0, 0, getWidth(), getHeight(), shadePaint);
// 剪裁中间区域
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
canvas.clipOutRect(cropRect);
} else {
canvas.clipRect(cropRect, android.graphics.Region.Op.DIFFERENCE);
}
canvas.drawColor(0x99000000);
canvas.restoreToCount(saveCount);
// 边框
canvas.drawRect(cropRect, borderPaint);
// 四角标记
float cornerSize = 20f;
canvas.drawLine(cropRect.left, cropRect.top, cropRect.left + cornerSize, cropRect.top, borderPaint);
canvas.drawLine(cropRect.left, cropRect.top, cropRect.left, cropRect.top + cornerSize, borderPaint);
canvas.drawLine(cropRect.right, cropRect.top, cropRect.right - cornerSize, cropRect.top, borderPaint);
canvas.drawLine(cropRect.right, cropRect.top, cropRect.right, cropRect.top + cornerSize, borderPaint);
canvas.drawLine(cropRect.right, cropRect.bottom, cropRect.right - cornerSize, cropRect.bottom, borderPaint);
canvas.drawLine(cropRect.right, cropRect.bottom, cropRect.right, cropRect.bottom - cornerSize, borderPaint);
canvas.drawLine(cropRect.left, cropRect.bottom, cropRect.left + cornerSize, cropRect.bottom, borderPaint);
canvas.drawLine(cropRect.left, cropRect.bottom, cropRect.left, cropRect.bottom - cornerSize, borderPaint);
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (videoBounds == null) return false;
float x = event.getX();
float y = event.getY();
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
lastRect.set(cropRect);
if (isInCorner(x, y, cropRect.left, cropRect.top)) dragHandle = 2;
else if (isInCorner(x, y, cropRect.right, cropRect.top)) dragHandle = 3;
else if (isInCorner(x, y, cropRect.right, cropRect.bottom)) dragHandle = 4;
else if (isInCorner(x, y, cropRect.left, cropRect.bottom)) dragHandle = 5;
else if (cropRect.contains(x, y)) dragHandle = 1;
else dragHandle = 0;
lastTouchX = x;
lastTouchY = y;
isDragging = dragHandle != 0;
return isDragging;
case MotionEvent.ACTION_MOVE:
if (!isDragging) return false;
lastRect.set(cropRect);
float dx = x - lastTouchX;
float dy = y - lastTouchY;
if (dragHandle == 1) {
cropRect.offset(dx, dy);
enforceBounds();
} else {
float newWidth, newHeight;
switch (dragHandle) {
case 2:
newWidth = cropRect.width() - dx;
newHeight = newWidth / aspectRatio;
cropRect.left = cropRect.right - newWidth;
cropRect.top = cropRect.bottom - newHeight;
break;
case 3:
newWidth = cropRect.width() + dx;
newHeight = newWidth / aspectRatio;
cropRect.right = cropRect.left + newWidth;
cropRect.top = cropRect.bottom - newHeight;
break;
case 4:
newWidth = cropRect.width() + dx;
newHeight = newWidth / aspectRatio;
cropRect.right = cropRect.left + newWidth;
cropRect.bottom = cropRect.top + newHeight;
break;
case 5:
newWidth = cropRect.width() - dx;
newHeight = newWidth / aspectRatio;
cropRect.left = cropRect.right - newWidth;
cropRect.bottom = cropRect.top + newHeight;
break;
}
if (cropRect.width() < minSize || cropRect.height() < minSize) {
cropRect.set(lastRect);
}
enforceBounds();
}
lastTouchX = x;
lastTouchY = y;
invalidate();
return true;
case MotionEvent.ACTION_UP:
isDragging = false;
return true;
}
return false;
}
private boolean isInCorner(float x, float y, float cornerX, float cornerY) {
float touchArea = 50f;
return x >= cornerX - touchArea && x <= cornerX + touchArea &&
y >= cornerY - touchArea && y <= cornerY + touchArea;
}
public void setAspectRatio(float ratio) {
this.aspectRatio = ratio;
if (videoBounds != null) {
float centerX = cropRect.centerX();
float centerY = cropRect.centerY();
float size = Math.max(cropRect.width(), cropRect.height());
updateCropRect(centerX, centerY, size);
}
}
public RectF getCropRect() {
return new RectF(cropRect);
}
@Nullable
public RectF getCropRectInVideoCoords(int videoWidth, int videoHeight) {
if (videoBounds == null) return null;
float scaleX = videoWidth / videoBounds.width();
float scaleY = videoHeight / videoBounds.height();
float left = (cropRect.left - videoBounds.left) * scaleX;
float top = (cropRect.top - videoBounds.top) * scaleY;
float width = cropRect.width() * scaleX;
float height = cropRect.height() * scaleY;
return new RectF(left, top, left + width, top + height);
}
}

View File

@ -0,0 +1,87 @@
package com.audio.record.screen.test.view
import android.content.Context
import android.graphics.PixelFormat
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.view.*
import android.view.animation.AccelerateDecelerateInterpolator
import android.widget.TextView
import com.audio.record.screen.test.R
object CountDownFloatingManager {
private var countdownView: View? = null
private var handler: Handler? = null
fun show(context: Context, onFinish: (() -> Unit)? = null) {
if (countdownView != null) return // Already showing
val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager
val inflater = LayoutInflater.from(context)
countdownView = inflater.inflate(R.layout.floating_view_countdown, null)
val tvCountdown = countdownView!!.findViewById<TextView>(R.id.tv_countdown)
val layoutParams = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY
else
WindowManager.LayoutParams.TYPE_PHONE,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
PixelFormat.TRANSLUCENT
)
layoutParams.gravity = Gravity.CENTER
windowManager.addView(countdownView, layoutParams)
val countdownValues = arrayOf("3", "2", "1")
var index = 0
handler = Handler(Looper.getMainLooper())
fun animateNext() {
if (index >= countdownValues.size) {
remove(windowManager)
onFinish?.invoke()
return
}
tvCountdown.text = countdownValues[index]
tvCountdown.scaleX = 0f
tvCountdown.scaleY = 0f
tvCountdown.alpha = 0f
tvCountdown.animate()
.scaleX(1f).scaleY(1f)
.alpha(1f)
.setDuration(400)
.setInterpolator(AccelerateDecelerateInterpolator())
.withEndAction {
handler?.postDelayed({
index++
animateNext()
}, 600)
}
.start()
}
animateNext()
}
fun remove(windowManager: WindowManager) {
handler?.removeCallbacksAndMessages(null)
handler = null
countdownView?.let {
try {
windowManager.removeView(it)
} catch (e: Exception) {
// view already removed
}
}
countdownView = null
}
}

View File

@ -0,0 +1,37 @@
package com.audio.record.screen.test.view
import android.graphics.Rect
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class GridSpacingItemDecoration(
private val spanCount: Int, // 列数
private val spacing: Int, // 间距px
private val includeEdge: Boolean // 是否包含边缘间距
) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(
outRect: Rect, view: View,
parent: RecyclerView, state: RecyclerView.State
) {
val position = parent.getChildAdapterPosition(view) // item position
val column = position % spanCount // item column
if (includeEdge) {
outRect.left = spacing - column * spacing / spanCount
outRect.right = (column + 1) * spacing / spanCount
if (position < spanCount) { // top edge
outRect.top = spacing
}
outRect.bottom = spacing // item bottom
} else {
outRect.left = column * spacing / spanCount
outRect.right = spacing - (column + 1) * spacing / spanCount
if (position >= spanCount) {
outRect.top = spacing // item top
}
}
}
}

View File

@ -0,0 +1,91 @@
package com.audio.record.screen.test.view
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import java.lang.Math.abs
class RangeSliderView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : View(context, attrs) {
private var leftThumbX = 100f
private var rightThumbX = 600f
private val thumbRadius = 30f
private val trackHeight = 20f
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
}
private val thumbPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
}
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.WHITE
strokeWidth = 6f
}
private var activeThumb: Thumb? = null
enum class Thumb { LEFT, RIGHT }
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// Draw selected range (red track)
val centerY = height / 2f
canvas.drawRect(leftThumbX, centerY - trackHeight / 2, rightThumbX, centerY + trackHeight / 2, trackPaint)
// Draw thumbs
canvas.drawRect(leftThumbX - thumbRadius, centerY - 2 * thumbRadius,
leftThumbX + thumbRadius, centerY + 2 * thumbRadius, thumbPaint)
canvas.drawRect(rightThumbX - thumbRadius, centerY - 2 * thumbRadius,
rightThumbX + thumbRadius, centerY + 2 * thumbRadius, thumbPaint)
// Draw white lines on thumbs
val lineHeight = 40f
canvas.drawLine(leftThumbX, centerY - lineHeight, leftThumbX, centerY + lineHeight, linePaint)
canvas.drawLine(rightThumbX, centerY - lineHeight, rightThumbX, centerY + lineHeight, linePaint)
}
override fun onTouchEvent(event: MotionEvent): Boolean {
val x = event.x
when (event.action) {
MotionEvent.ACTION_DOWN -> {
activeThumb = when {
isInsideThumb(x, leftThumbX) -> Thumb.LEFT
isInsideThumb(x, rightThumbX) -> Thumb.RIGHT
else -> null
}
}
MotionEvent.ACTION_MOVE -> {
activeThumb?.let {
when (it) {
Thumb.LEFT -> {
leftThumbX = x.coerceIn(0f, rightThumbX - 100f)
}
Thumb.RIGHT -> {
rightThumbX = x.coerceIn(leftThumbX + 100f, width.toFloat())
}
}
invalidate()
}
}
MotionEvent.ACTION_UP -> {
activeThumb = null
}
}
return true
}
private fun isInsideThumb(touchX: Float, thumbX: Float): Boolean {
return abs(touchX - thumbX) <= thumbRadius * 2
}
}

View File

@ -0,0 +1,44 @@
package com.audio.record.screen.test.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class MainViewModel : ViewModel() {
//服务连接
private val _serviceConnectStatus = MutableLiveData<Boolean>()
val serviceConnectStatus: LiveData<Boolean> get() = _serviceConnectStatus
//前置摄像头状态
private val _webcamStatus = MutableLiveData<Boolean>()
val webcamStatus: LiveData<Boolean> get() = _webcamStatus
//截屏状态
private val _screenshotStatus = MutableLiveData<Boolean>()
val screenshotStatus: LiveData<Boolean> get() = _screenshotStatus
//悬浮球状态
private val _ballStatus = MutableLiveData<Boolean>()
val ballStatus: LiveData<Boolean> get() = _ballStatus
fun updateServiceConnectStatus(message: Boolean) {
_serviceConnectStatus.value = message
}
fun updateBallStatus(message: Boolean) {
_ballStatus.value = message
}
fun updateWebcamStatus(message: Boolean) {
_webcamStatus.value = message
}
fun updateScreenshotStatus(message: Boolean) {
_screenshotStatus.value = message
}
}

View File

@ -0,0 +1,46 @@
package com.audio.record.screen.test.viewmodel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
class PreviewViewModel : ViewModel() {
//裁剪比例选择变化
private val _cropRatioText = MutableLiveData<String>()
val cropRatioText: LiveData<String> get() = _cropRatioText
//复制当前文件到内部存储的结果
private val _copySuccess = MutableLiveData<Pair<Boolean,String>>()
val copySuccess: LiveData<Pair<Boolean,String>> get() = _copySuccess
//保存裁剪文件
private val _saveCrop = MutableLiveData<Boolean>()
val saveCrop: LiveData<Boolean> get() = _saveCrop
//更新当前播放速度
private val _changeSpeed = MutableLiveData<Float>()
val changeSpeed: LiveData<Float> get() = _changeSpeed
fun updateCropText(message: String) {
_cropRatioText.value = message
}
fun updateCopyResult(message: Pair<Boolean,String>) {
_copySuccess.value = message
}
fun updateClickCropSave(message: Boolean) {
_saveCrop.value = message
}
fun updateSpeed(message: Float) {
_changeSpeed.value = message
}
}

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:color="@color/color_978080" android:state_selected="false"/>
<item android:color="@color/white" android:state_selected="true"/>
</selector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="18dp"/>
<solid android:color="@color/btn_off"/>
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="@color/btn_on"/>
<corners android:radius="18dp"/>
</shape>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:state_selected="false" android:drawable="@drawable/btn_off"/>
<item android:state_selected="true" android:drawable="@drawable/btn_on"/>
</selector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="256dp"
android:height="256dp"
android:viewportWidth="1024"
android:viewportHeight="1024">
<path
android:pathData="M512,570.9l196.9,196.8 58.9,-58.9L570.8,512l196.9,-196.9 -58.8,-58.9L512,453.2 315.1,256.3l-58.9,58.9L453.2,512l-196.9,196.9 58.9,58.9z"
android:fillColor="#28354C"/>
</vector>

View File

@ -0,0 +1,14 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="77dp"
android:height="77dp"
android:viewportWidth="77"
android:viewportHeight="77">
<path
android:pathData="M38.5,38.5m-37.5,0a37.5,37.5 0,1 1,75 0a37.5,37.5 0,1 1,-75 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#D5D5D5"/>
<path
android:pathData="M50.489,28.96L40.794,38.654L51,48.86L49.469,50.391L39.264,40.186L29.059,50.391L27.527,48.86L37.732,38.654L28.038,28.96L29.568,27.429L39.264,37.124L48.959,27.429L50.489,28.96Z"
android:fillColor="#D9D9D9"/>
</vector>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:topLeftRadius="22dp" android:topRightRadius="22dp"/>
<solid android:color="@color/color_F6F5F5"/>
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="14dp"/>
<solid android:color="@color/tab_selected_color"/>
</shape>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="13dp"/>
<solid android:color="@color/white"/>
</shape>

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="77dp"
android:height="77dp"
android:viewportWidth="77"
android:viewportHeight="77">
<path
android:pathData="M38.5,38.5m-38.5,0a38.5,38.5 0,1 1,77 0a38.5,38.5 0,1 1,-77 0"
android:fillColor="#FD5965"/>
<path
android:pathData="M41.923,20.262C42.733,21.071 43.188,22.169 43.188,23.315V53.919C43.188,56.302 41.256,58.234 38.873,58.234C37.727,58.234 36.629,57.779 35.82,56.969L27.972,49.114C27.53,48.672 26.931,48.424 26.306,48.424H23.278C19.811,48.424 17,45.614 17,42.147V35.087C17,31.62 19.811,28.81 23.278,28.81H26.306C26.931,28.81 27.53,28.562 27.972,28.12L35.82,20.265C37.504,18.579 40.236,18.578 41.923,20.262Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M57.36,39.252C57.36,32.002 52.735,26.665 47.617,26.624L47.557,26.623L47.521,26.623C46.782,26.604 46.188,25.999 46.188,25.254C46.188,24.498 46.8,23.885 47.557,23.885L47.641,23.885C54.797,23.941 60.098,31.101 60.098,39.252C60.098,47.434 54.755,54.618 47.557,54.618C46.8,54.618 46.188,54.005 46.188,53.249C46.188,52.493 46.8,51.88 47.557,51.88C52.699,51.88 57.36,46.53 57.36,39.252Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M49.506,39.251C49.506,37.621 48.636,36.193 47.327,35.407L47.265,35.37L47.231,35.35C46.525,34.917 46.289,33.997 46.706,33.276C47.123,32.555 48.038,32.301 48.765,32.698L48.8,32.717L48.853,32.748C51.074,34.058 52.571,36.479 52.571,39.251C52.571,42.045 51.051,44.483 48.8,45.785C48.067,46.208 47.129,45.958 46.706,45.225C46.282,44.493 46.532,43.555 47.265,43.131C48.607,42.355 49.506,40.907 49.506,39.251Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="77dp"
android:height="77dp"
android:viewportWidth="77"
android:viewportHeight="77">
<path
android:pathData="M38.5,38.5m-38.5,0a38.5,38.5 0,1 1,77 0a38.5,38.5 0,1 1,-77 0"
android:fillColor="#FD5965"/>
<group>
<clip-path
android:pathData="M17,19h42v39h-42z"/>
<group>
<clip-path
android:pathData="M59,19H17.007V58H59V19Z"/>
<path
android:pathData="M41.783,20.314C42.589,21.117 43.041,22.206 43.041,23.343V53.717C43.041,56.082 41.12,58 38.75,58C37.611,58 36.519,57.548 35.714,56.744L27.91,48.949C27.471,48.51 26.875,48.264 26.255,48.264H23.243C19.795,48.264 17.001,45.474 17.001,42.035V35.027C17.001,31.586 19.795,28.798 23.243,28.798H26.255C26.876,28.798 27.471,28.551 27.91,28.112L35.714,20.317C37.389,18.644 40.106,18.642 41.783,20.314ZM58.495,32.089C59.105,32.698 59.105,33.686 58.495,34.295L54.19,38.592L58.495,42.889C59.105,43.498 59.105,44.486 58.495,45.095C57.884,45.705 56.895,45.705 56.284,45.095L51.979,40.799L47.674,45.095C47.063,45.705 46.074,45.705 45.463,45.095C44.853,44.486 44.853,43.498 45.463,42.889L49.769,38.593L45.463,34.296C44.852,33.687 44.852,32.699 45.463,32.09C46.073,31.48 47.063,31.48 47.673,32.09L51.978,36.386L56.284,32.09C56.895,31.48 57.884,31.48 58.495,32.09L58.495,32.089Z"
android:fillColor="#ffffff"/>
</group>
</group>
</vector>

View File

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="36"
android:viewportHeight="36">
<path
android:pathData="M17.873,17.873m-16.873,0a16.873,16.873 0,1 1,33.745 0a16.873,16.873 0,1 1,-33.745 0"
android:strokeWidth="1.5"
android:fillColor="#FD5965"
android:strokeColor="#000000"/>
<path
android:pathData="M8.499,14.263C8.499,13.15 9.401,12.248 10.514,12.248H20.501C21.614,12.248 22.516,13.15 22.516,14.263V21.655C22.516,22.768 21.614,23.67 20.501,23.67H10.514C9.401,23.67 8.499,22.768 8.499,21.655V14.263Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M20.307,18.821C19.659,18.429 19.659,17.489 20.307,17.097L25.53,13.938C26.201,13.532 27.059,14.015 27.059,14.8L27.059,21.118C27.059,21.903 26.201,22.386 25.53,21.98L20.307,18.821Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="36dp"
android:height="36dp"
android:viewportWidth="36"
android:viewportHeight="36">
<path
android:pathData="M17.873,17.873m-16.873,0a16.873,16.873 0,1 1,33.745 0a16.873,16.873 0,1 1,-33.745 0"
android:strokeWidth="1.5"
android:fillColor="#FD5965"
android:strokeColor="#000000"/>
<path
android:pathData="M10.923,8H12.374V22.681H27V24.077H10.923V8Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M24.077,27H22.681V12.374H8V10.923H24.077V27Z"
android:fillColor="#ffffff"/>
<path
android:pathData="M25.538,10.325L14.805,21.154L13.846,20.291L24.58,9.461L25.538,10.325Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

View File

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="56dp"
android:height="56dp"
android:viewportWidth="56"
android:viewportHeight="56">
<path
android:pathData="M28,28m-28,0a28,28 0,1 1,56 0a28,28 0,1 1,-56 0"
android:fillColor="#FD5965"/>
<path
android:pathData="M28,28m14,0a14,14 0,1 0,-28 0a14,14 0,1 0,28 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"/>
<path
android:pathData="M27.788,28.212m7.212,0a7.212,7.212 0,1 0,-14.424 0a7.212,7.212 0,1 0,14.424 0"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,17 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="56dp"
android:height="56dp"
android:viewportWidth="56"
android:viewportHeight="56">
<path
android:pathData="M28,28m-28,0a28,28 0,1 1,56 0a28,28 0,1 1,-56 0"
android:fillColor="#FFF0F1"/>
<path
android:pathData="M28,28m14,0a14,14 0,1 0,-28 0a14,14 0,1 0,28 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#E63946"/>
<path
android:pathData="M28,28m7.212,0a7.212,7.212 0,1 0,-14.424 0a7.212,7.212 0,1 0,14.424 0"
android:fillColor="#E63946"/>
</vector>

View File

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="77dp"
android:height="77dp"
android:viewportWidth="77"
android:viewportHeight="77">
<path
android:pathData="M38.5,38.5m-33.5,0a33.5,33.5 0,1 1,67 0a33.5,33.5 0,1 1,-67 0"
android:fillColor="#FD5965"/>
<path
android:strokeWidth="1"
android:pathData="M38.5,38.5m-38,0a38,38 0,1 1,76 0a38,38 0,1 1,-76 0"
android:fillColor="#00000000"
android:strokeColor="#FD5965"/>
<path
android:pathData="M24.461,27.677L44.292,27.677A4,4 0,0 1,48.292 31.677L48.292,46.354A4,4 0,0 1,44.292 50.354L24.461,50.354A4,4 0,0 1,20.461 46.354L20.461,31.677A4,4 0,0 1,24.461 27.677z"
android:fillColor="#ffffff"/>
<path
android:pathData="M43.906,40.727C42.62,39.949 42.62,38.082 43.906,37.304L54.276,31.032C55.609,30.225 57.312,31.185 57.312,32.743L57.312,45.288C57.312,46.845 55.609,47.805 54.276,46.999L43.906,40.727Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="29dp"
android:height="38dp"
android:viewportWidth="29"
android:viewportHeight="38">
<path
android:pathData="M1,0L10,0A1,1 0,0 1,11 1L11,37A1,1 0,0 1,10 38L1,38A1,1 0,0 1,0 37L0,1A1,1 0,0 1,1 0z"
android:fillColor="#ffffff"/>
<path
android:pathData="M19,0L28,0A1,1 0,0 1,29 1L29,37A1,1 0,0 1,28 38L19,38A1,1 0,0 1,18 37L18,1A1,1 0,0 1,19 0z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="28dp"
android:height="32dp"
android:viewportWidth="28"
android:viewportHeight="32">
<path
android:pathData="M26.901,14.257C28.261,15.021 28.261,16.979 26.901,17.743L3.731,30.778C2.397,31.528 0.75,30.564 0.75,29.035L0.75,2.965C0.75,1.436 2.397,0.472 3.731,1.222L26.901,14.257Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="77dp"
android:height="77dp"
android:viewportWidth="77"
android:viewportHeight="77">
<path
android:pathData="M38.5,38.5m-38.5,0a38.5,38.5 0,1 1,77 0a38.5,38.5 0,1 1,-77 0"
android:fillColor="#ffffff"/>
<path
android:pathData="M29,26L47,26A3,3 0,0 1,50 29L50,47A3,3 0,0 1,47 50L29,50A3,3 0,0 1,26 47L26,29A3,3 0,0 1,29 26z"
android:fillColor="#ED606A"/>
</vector>

View File

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="56dp"
android:height="56dp"
android:viewportWidth="56"
android:viewportHeight="56">
<path
android:pathData="M28,28m-28,0a28,28 0,1 1,56 0a28,28 0,1 1,-56 0"
android:fillColor="#FD5965"/>
<path
android:pathData="M20.26,16V37.039H40.422"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"
android:strokeLineCap="square"/>
<path
android:pathData="M36.039,40.545V20.383H15"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"
android:strokeLineCap="square"/>
<path
android:pathData="M23.766,33.532L38.669,17.753"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"
android:strokeLineCap="square"/>
</vector>

View File

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="56dp"
android:height="56dp"
android:viewportWidth="56"
android:viewportHeight="56">
<path
android:pathData="M28,28m-28,0a28,28 0,1 1,56 0a28,28 0,1 1,-56 0"
android:fillColor="#FFF0F1"/>
<path
android:pathData="M20.26,16V37.039H40.422"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#E63946"
android:strokeLineCap="square"/>
<path
android:pathData="M36.039,40.545V20.383H15"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#E63946"
android:strokeLineCap="square"/>
<path
android:pathData="M23.766,33.532L38.669,17.753"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#F4878F"
android:strokeLineCap="square"/>
</vector>

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="46dp"
android:height="46dp"
android:viewportWidth="46"
android:viewportHeight="46">
<path
android:pathData="M23,23m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0"
android:strokeWidth="2"
android:fillColor="#000000"
android:fillAlpha="0.48"
android:strokeColor="#ffffff"/>
<path
android:pathData="M16,14h4v18h-4z"
android:fillColor="#ffffff"/>
<path
android:pathData="M26,14h4v18h-4z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="46dp"
android:height="46dp"
android:viewportWidth="46"
android:viewportHeight="46">
<path
android:pathData="M23,23m-22,0a22,22 0,1 1,44 0a22,22 0,1 1,-44 0"
android:strokeWidth="2"
android:fillColor="#000000"
android:fillAlpha="0.48"
android:strokeColor="#ffffff"/>
<path
android:pathData="M33,22.5L17.25,31.593L17.25,13.407L33,22.5Z"
android:fillColor="#ffffff"/>
</vector>

View File

@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="56dp"
android:height="56dp"
android:viewportWidth="56"
android:viewportHeight="56">
<path
android:pathData="M28,28m-28,0a28,28 0,1 1,56 0a28,28 0,1 1,-56 0"
android:fillColor="#FD5965"/>
<path
android:pathData="M28.227,26.227m-9.227,0a9.227,9.227 0,1 1,18.455 0a9.227,9.227 0,1 1,-18.455 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"/>
<path
android:pathData="M28.227,26.227m-4.318,0a4.318,4.318 0,1 1,8.636 0a4.318,4.318 0,1 1,-8.636 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"/>
<path
android:pathData="M22.091,40.545C24.039,38.693 29.221,36.1 34.363,40.545"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,25 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="56dp"
android:height="56dp"
android:viewportWidth="56"
android:viewportHeight="56">
<path
android:pathData="M28,28m-28,0a28,28 0,1 1,56 0a28,28 0,1 1,-56 0"
android:fillColor="#FFF0F1"/>
<path
android:pathData="M28.227,26.227m-9.227,0a9.227,9.227 0,1 1,18.455 0a9.227,9.227 0,1 1,-18.455 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#E63946"/>
<path
android:pathData="M28.227,26.227m-4.318,0a4.318,4.318 0,1 1,8.636 0a4.318,4.318 0,1 1,-8.636 0"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#E63946"/>
<path
android:pathData="M22.091,40.545C24.039,38.693 29.221,36.1 34.364,40.545"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#E63946"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,18 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="19dp"
android:viewportWidth="24"
android:viewportHeight="19">
<path
android:pathData="M10,1L2,9.5L10,18"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"
android:strokeLineCap="round"/>
<path
android:pathData="M3,9H23"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#ffffff"
android:strokeLineCap="round"/>
</vector>

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="12dp"/>
<solid android:color="@color/tab_selected_color"/>
</shape>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="-90"
android:endColor="@color/color_61"
android:startColor="@color/color_16" />
</shape>

View File

@ -0,0 +1,21 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="5dp" />
<solid android:color="@color/color_737373" /> <!-- 背景颜色 -->
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="20dp" />
<gradient
android:startColor="@color/test_yellow"
android:centerColor="@color/test_yellow"
android:endColor="@color/test_yellow"
android:angle="0" />
</shape>
</clip>
</item>
</layer-list>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="90"
android:centerColor="@color/color_61"
android:endColor="@color/color_CC000000"
android:startColor="@color/color_transparent" />
</shape>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<gradient
android:angle="-90"
android:centerColor="@color/color_61"
android:endColor="@color/color_CC000000"
android:startColor="@color/color_transparent" />
</shape>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 背景 -->
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
</shape>
</item>
<!-- 左侧透明边框 -->
<item android:left="-2dp" android:right="2dp"> <!-- 注意这里的偏移量 -->
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
<stroke android:width="2dp" android:color="#FF0000" android:dashGap="0dp" android:dashWidth="0dp"/>
</shape>
</item>
<!-- 右侧透明边框 -->
<item android:left="2dp" android:right="-2dp"> <!-- 注意这里的偏移量 -->
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" />
<stroke android:width="2dp" android:color="#FF0000" android:dashGap="0dp" android:dashWidth="0dp"/>
</shape>
</item>
<!-- 顶部和底部实色边框 -->
<item>
<shape android:shape="rectangle">
<solid android:color="@android:color/transparent" /> <!-- 背景透明 -->
<stroke android:width="2dp" android:color="#FF0000"/> <!-- 边框颜色 -->
</shape>
</item>
</layer-list>

Some files were not shown because too many files have changed in this diff Show More