|
|
@ -41,6 +41,7 @@ import okhttp3.OkHttpClient |
|
|
import okhttp3.Request |
|
|
import okhttp3.Request |
|
|
import okhttp3.RequestBody.Companion.toRequestBody |
|
|
import okhttp3.RequestBody.Companion.toRequestBody |
|
|
import org.json.JSONObject |
|
|
import org.json.JSONObject |
|
|
|
|
|
import java.util.concurrent.TimeUnit |
|
|
|
|
|
|
|
|
class MainActivity : AppCompatActivity() { |
|
|
class MainActivity : AppCompatActivity() { |
|
|
|
|
|
|
|
|
@ -88,7 +89,7 @@ class MainActivity : AppCompatActivity() { |
|
|
setupUI() |
|
|
setupUI() |
|
|
checkPermissions() |
|
|
checkPermissions() |
|
|
|
|
|
|
|
|
AppLogger.log("Main", "اپلیکیشن راهاندازی شد (طراحی ریسپانسیو و فوکوس کامل اینپوتباکس روی کیبورد)") |
|
|
|
|
|
|
|
|
AppLogger.log("Main", "اپلیکیشن همگامسازی صوتی سانیاوکس راهاندازی شد (نسخه بهینهشده فوقسریع v5.6)") |
|
|
|
|
|
|
|
|
// Initialize Collaborative WebSocket Client |
|
|
// Initialize Collaborative WebSocket Client |
|
|
streamDictationClient = StreamDictationClient( |
|
|
streamDictationClient = StreamDictationClient( |
|
|
@ -100,12 +101,25 @@ class MainActivity : AppCompatActivity() { |
|
|
) |
|
|
) |
|
|
}, |
|
|
}, |
|
|
onSyncStateReceived = { state -> |
|
|
onSyncStateReceived = { state -> |
|
|
// Drop echoes and do not interrupt active user editing on phone |
|
|
|
|
|
|
|
|
// Drop echoes originated from phone itself |
|
|
if (state.source != "android") { |
|
|
if (state.source != "android") { |
|
|
val timeSinceLocalEdit = System.currentTimeMillis() - lastLocalUserEditTime |
|
|
val timeSinceLocalEdit = System.currentTimeMillis() - lastLocalUserEditTime |
|
|
|
|
|
|
|
|
// If user is actively typing or backspacing right now on phone, do not override with remote echo |
|
|
|
|
|
if (timeSinceLocalEdit < 1000L && binding.etTranscript.hasFocus()) { |
|
|
|
|
|
|
|
|
// If user is actively typing or recording right now on phone, do not override |
|
|
|
|
|
if ((timeSinceLocalEdit < 1000L && binding.etTranscript.hasFocus()) || isCurrentlyRecording) { |
|
|
|
|
|
return@StreamDictationClient |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Monotonic revision check |
|
|
|
|
|
if (state.revision > 0 && state.revision < currentRevision) { |
|
|
|
|
|
return@StreamDictationClient |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// Ghost empty sync protection for opaque apps |
|
|
|
|
|
if (state.text.isEmpty() && lastLocalText.isNotEmpty() && state.source == "mac") { |
|
|
|
|
|
if (state.app.isNotEmpty() && state.app != "Desktop" && state.app != "App") { |
|
|
|
|
|
binding.tvMacStatus.text = "متصل به ${state.app} 🖥️" |
|
|
|
|
|
} |
|
|
return@StreamDictationClient |
|
|
return@StreamDictationClient |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -137,15 +151,17 @@ class MainActivity : AppCompatActivity() { |
|
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_blue)) |
|
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_blue)) |
|
|
}, |
|
|
}, |
|
|
onAudioLevel = { level -> |
|
|
onAudioLevel = { level -> |
|
|
val scale = 1.0f + (level * 0.35f) |
|
|
|
|
|
binding.viewGlow.scaleX = scale |
|
|
|
|
|
binding.viewGlow.scaleY = scale |
|
|
|
|
|
|
|
|
if (isCurrentlyRecording) { |
|
|
|
|
|
val scale = 1.0f + (level * 0.22f) |
|
|
|
|
|
binding.viewGlow.scaleX = scale |
|
|
|
|
|
binding.viewGlow.scaleY = scale |
|
|
|
|
|
} |
|
|
}, |
|
|
}, |
|
|
onSpeechCompleted = { finalText -> |
|
|
onSpeechCompleted = { finalText -> |
|
|
vibrate(100) |
|
|
vibrate(100) |
|
|
if (finalText.isNotEmpty()) { |
|
|
if (finalText.isNotEmpty()) { |
|
|
insertSpeechAtCursor(finalText) |
|
|
insertSpeechAtCursor(finalText) |
|
|
binding.tvInstruction.text = "✨ گفتار در نشانگر درج و با مک همگام شد" |
|
|
|
|
|
|
|
|
binding.tvInstruction.text = "✨ گفتار با مک همگام شد" |
|
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_green)) |
|
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_green)) |
|
|
} else { |
|
|
} else { |
|
|
binding.tvInstruction.text = "صدایی تشخیص داده نشد" |
|
|
binding.tvInstruction.text = "صدایی تشخیص داده نشد" |
|
|
@ -160,18 +176,15 @@ class MainActivity : AppCompatActivity() { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* Dual-engine keyboard visibility detector (WindowInsets + OnGlobalLayoutListener) |
|
|
|
|
|
* Guarantees 100% detection on all Android versions and keyboards. |
|
|
|
|
|
|
|
|
* Dual-engine keyboard visibility detector |
|
|
*/ |
|
|
*/ |
|
|
private fun setupKeyboardVisibilityDetection() { |
|
|
private fun setupKeyboardVisibilityDetection() { |
|
|
// Engine 1: Modern WindowInsets |
|
|
|
|
|
ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets -> |
|
|
ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets -> |
|
|
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) |
|
|
val imeVisible = insets.isVisible(WindowInsetsCompat.Type.ime()) |
|
|
updateKeyboardUIMode(imeVisible) |
|
|
updateKeyboardUIMode(imeVisible) |
|
|
insets |
|
|
insets |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
// Engine 2: Global Layout Frame Calculation (Fallback for OEM soft keyboards) |
|
|
|
|
|
binding.rootLayout.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { |
|
|
binding.rootLayout.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener { |
|
|
private val r = Rect() |
|
|
private val r = Rect() |
|
|
override fun onGlobalLayout() { |
|
|
override fun onGlobalLayout() { |
|
|
@ -189,14 +202,11 @@ class MainActivity : AppCompatActivity() { |
|
|
isKeyboardCurrentlyVisible = isKeyboardOpen |
|
|
isKeyboardCurrentlyVisible = isKeyboardOpen |
|
|
|
|
|
|
|
|
if (isKeyboardOpen) { |
|
|
if (isKeyboardOpen) { |
|
|
// KEYBOARD OPEN: Hide big mic circle, hide action buttons & divider completely! |
|
|
|
|
|
// Give 100% of available space above keyboard exclusively to the huge text editor box! |
|
|
|
|
|
binding.bottomVoiceSection.visibility = View.GONE |
|
|
binding.bottomVoiceSection.visibility = View.GONE |
|
|
binding.actionDivider.visibility = View.GONE |
|
|
binding.actionDivider.visibility = View.GONE |
|
|
binding.actionButtonsRow.visibility = View.GONE |
|
|
binding.actionButtonsRow.visibility = View.GONE |
|
|
binding.tvSubtitle.visibility = View.GONE |
|
|
binding.tvSubtitle.visibility = View.GONE |
|
|
} else { |
|
|
} else { |
|
|
// KEYBOARD CLOSED: Restore spacious layout with large glowing mic button & action bar |
|
|
|
|
|
binding.bottomVoiceSection.visibility = View.VISIBLE |
|
|
binding.bottomVoiceSection.visibility = View.VISIBLE |
|
|
binding.actionDivider.visibility = View.VISIBLE |
|
|
binding.actionDivider.visibility = View.VISIBLE |
|
|
binding.actionButtonsRow.visibility = View.VISIBLE |
|
|
binding.actionButtonsRow.visibility = View.VISIBLE |
|
|
@ -205,6 +215,13 @@ class MainActivity : AppCompatActivity() { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private fun insertSpeechAtCursor(speechText: String) { |
|
|
private fun insertSpeechAtCursor(speechText: String) { |
|
|
|
|
|
val trimmedSpeech = speechText.trim() |
|
|
|
|
|
|
|
|
|
|
|
// Suppress empty strings or lone quotes/punctuation marks |
|
|
|
|
|
if (trimmedSpeech.isEmpty() || trimmedSpeech.matches("^[\\s«»\\.\\,\\،\\؛\\؟\\!\\?\\:\\;\\-\\–—\\\"\\'\\(\\)\\[\\]\\{\\}]+$".toRegex())) { |
|
|
|
|
|
return |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
val current = binding.etTranscript.text?.toString() ?: "" |
|
|
val current = binding.etTranscript.text?.toString() ?: "" |
|
|
val start = voiceInsertionCursorStart.coerceIn(0, current.length) |
|
|
val start = voiceInsertionCursorStart.coerceIn(0, current.length) |
|
|
val end = voiceInsertionCursorEnd.coerceIn(0, current.length) |
|
|
val end = voiceInsertionCursorEnd.coerceIn(0, current.length) |
|
|
@ -212,15 +229,24 @@ class MainActivity : AppCompatActivity() { |
|
|
val prefix = if (start > 0) current.substring(0, start) else "" |
|
|
val prefix = if (start > 0) current.substring(0, start) else "" |
|
|
val suffix = if (end < current.length) current.substring(end) else "" |
|
|
val suffix = if (end < current.length) current.substring(end) else "" |
|
|
|
|
|
|
|
|
val formattedSpeech = if (prefix.isNotEmpty() && !prefix.endsWith(" ") && !speechText.startsWith(" ")) { |
|
|
|
|
|
" $speechText" |
|
|
|
|
|
} else { |
|
|
|
|
|
speechText |
|
|
|
|
|
|
|
|
val needsPreSpace = prefix.isNotEmpty() && !prefix.endsWith(" ") && !prefix.endsWith("\n") |
|
|
|
|
|
val needsPostSpace = suffix.isNotEmpty() && !suffix.startsWith(" ") && !suffix.startsWith("\n") && |
|
|
|
|
|
!suffix.startsWith(",") && !suffix.startsWith("،") && !suffix.startsWith(".") && |
|
|
|
|
|
!suffix.startsWith("؟") && !suffix.startsWith("!") && !suffix.startsWith(":") |
|
|
|
|
|
|
|
|
|
|
|
val formattedSpeech = buildString { |
|
|
|
|
|
if (needsPreSpace) append(" ") |
|
|
|
|
|
append(trimmedSpeech) |
|
|
|
|
|
if (needsPostSpace) append(" ") |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
val mergedText = "$prefix$formattedSpeech$suffix" |
|
|
val mergedText = "$prefix$formattedSpeech$suffix" |
|
|
val newCursor = (start + formattedSpeech.length).coerceIn(0, mergedText.length) |
|
|
val newCursor = (start + formattedSpeech.length).coerceIn(0, mergedText.length) |
|
|
|
|
|
|
|
|
|
|
|
// Cancel any pending debounced sync |
|
|
|
|
|
pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } |
|
|
|
|
|
pendingSyncRunnable = null |
|
|
|
|
|
|
|
|
isApplyingRemoteUpdate = true |
|
|
isApplyingRemoteUpdate = true |
|
|
lastLocalText = mergedText |
|
|
lastLocalText = mergedText |
|
|
lastLocalUserEditTime = System.currentTimeMillis() |
|
|
lastLocalUserEditTime = System.currentTimeMillis() |
|
|
@ -228,9 +254,10 @@ class MainActivity : AppCompatActivity() { |
|
|
binding.etTranscript.setSelection(newCursor) |
|
|
binding.etTranscript.setSelection(newCursor) |
|
|
isApplyingRemoteUpdate = false |
|
|
isApplyingRemoteUpdate = false |
|
|
|
|
|
|
|
|
AppLogger.log("Main", "تزریق گفتار در نشانگر: '$speechText' (موقعیت جدید: $newCursor)") |
|
|
|
|
|
|
|
|
AppLogger.log("Main", "تزریق گفتار در نشانگر: '$trimmedSpeech' (موقعیت جدید: $newCursor)") |
|
|
|
|
|
|
|
|
streamDictationClient?.sendLocalSyncState(mergedText, newCursor) |
|
|
|
|
|
|
|
|
// Broadcast full mirrored text to Mac |
|
|
|
|
|
streamDictationClient?.sendPhoneEdit(mergedText, newCursor) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private fun setupUI() { |
|
|
private fun setupUI() { |
|
|
@ -247,25 +274,36 @@ class MainActivity : AppCompatActivity() { |
|
|
lastLocalUserEditTime = System.currentTimeMillis() |
|
|
lastLocalUserEditTime = System.currentTimeMillis() |
|
|
val cur = binding.etTranscript.selectionStart.coerceIn(0, text.length) |
|
|
val cur = binding.etTranscript.selectionStart.coerceIn(0, text.length) |
|
|
|
|
|
|
|
|
// Debounce sending to Mac by 60ms to allow 120Hz lag-free backspacing and typing |
|
|
|
|
|
|
|
|
// Immediate sync for newlines (\n), crisp 35ms debounce for general typing |
|
|
|
|
|
val isNewlineEdit = count == 1 && s?.subSequence(start, start + count)?.contains('\n') == true |
|
|
|
|
|
val delayMs = if (isNewlineEdit) 0L else 35L |
|
|
|
|
|
|
|
|
pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } |
|
|
pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } |
|
|
pendingSyncRunnable = Runnable { |
|
|
pendingSyncRunnable = Runnable { |
|
|
streamDictationClient?.sendLocalSyncState(text, cur) |
|
|
|
|
|
|
|
|
streamDictationClient?.sendPhoneEdit(text, cur) |
|
|
|
|
|
} |
|
|
|
|
|
if (delayMs == 0L) { |
|
|
|
|
|
debounceHandler.post(pendingSyncRunnable!!) |
|
|
|
|
|
} else { |
|
|
|
|
|
debounceHandler.postDelayed(pendingSyncRunnable!!, delayMs) |
|
|
} |
|
|
} |
|
|
debounceHandler.postDelayed(pendingSyncRunnable!!, 60) |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
override fun afterTextChanged(s: Editable?) {} |
|
|
override fun afterTextChanged(s: Editable?) {} |
|
|
}) |
|
|
}) |
|
|
|
|
|
|
|
|
// Clear Button |
|
|
|
|
|
|
|
|
// Clear Button (🗑️ پاککردن) |
|
|
binding.btnClearText.setOnClickListener { |
|
|
binding.btnClearText.setOnClickListener { |
|
|
isApplyingRemoteUpdate = true |
|
|
isApplyingRemoteUpdate = true |
|
|
binding.etTranscript.setText("") |
|
|
binding.etTranscript.setText("") |
|
|
lastLocalText = "" |
|
|
lastLocalText = "" |
|
|
lastLocalUserEditTime = System.currentTimeMillis() |
|
|
lastLocalUserEditTime = System.currentTimeMillis() |
|
|
isApplyingRemoteUpdate = false |
|
|
isApplyingRemoteUpdate = false |
|
|
streamDictationClient?.sendLocalSyncState("", 0) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
pendingSyncRunnable?.let { debounceHandler.removeCallbacks(it) } |
|
|
|
|
|
pendingSyncRunnable = null |
|
|
|
|
|
|
|
|
|
|
|
streamDictationClient?.sendPhoneEdit("", 0) |
|
|
binding.tvInstruction.text = getString(R.string.hold_to_speak) |
|
|
binding.tvInstruction.text = getString(R.string.hold_to_speak) |
|
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.text_secondary)) |
|
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.text_secondary)) |
|
|
} |
|
|
} |
|
|
@ -293,8 +331,8 @@ class MainActivity : AppCompatActivity() { |
|
|
binding.tvInstruction.text = "در حال درج متن در مک..." |
|
|
binding.tvInstruction.text = "در حال درج متن در مک..." |
|
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) |
|
|
binding.tvInstruction.setTextColor(ContextCompat.getColor(this, R.color.accent_yellow)) |
|
|
|
|
|
|
|
|
// 1. Direct WebSocket broadcast |
|
|
|
|
|
streamDictationClient?.sendLocalSyncState(text, cur) |
|
|
|
|
|
|
|
|
// 1. Direct WebSocket broadcast with force_replace |
|
|
|
|
|
streamDictationClient?.sendForceReplace(text, cur) |
|
|
|
|
|
|
|
|
// 2. Direct HTTP Post guarantee |
|
|
// 2. Direct HTTP Post guarantee |
|
|
lifecycleScope.launch { |
|
|
lifecycleScope.launch { |
|
|
@ -323,12 +361,16 @@ class MainActivity : AppCompatActivity() { |
|
|
return when (event.action) { |
|
|
return when (event.action) { |
|
|
MotionEvent.ACTION_DOWN -> { |
|
|
MotionEvent.ACTION_DOWN -> { |
|
|
if (checkAudioPermission()) { |
|
|
if (checkAudioPermission()) { |
|
|
|
|
|
// Fast sync: ensure socket is connected immediately |
|
|
|
|
|
streamDictationClient?.connectWebSocket() |
|
|
|
|
|
|
|
|
val selStart = binding.etTranscript.selectionStart |
|
|
val selStart = binding.etTranscript.selectionStart |
|
|
val selEnd = binding.etTranscript.selectionEnd |
|
|
val selEnd = binding.etTranscript.selectionEnd |
|
|
val totalLen = binding.etTranscript.text?.length ?: 0 |
|
|
|
|
|
|
|
|
val currentText = binding.etTranscript.text?.toString() ?: "" |
|
|
|
|
|
val totalLen = currentText.length |
|
|
|
|
|
|
|
|
voiceInsertionCursorStart = if (selStart >= 0) selStart else totalLen |
|
|
|
|
|
voiceInsertionCursorEnd = if (selEnd >= 0) selEnd else totalLen |
|
|
|
|
|
|
|
|
voiceInsertionCursorStart = if (selStart in 0..totalLen) selStart else totalLen |
|
|
|
|
|
voiceInsertionCursorEnd = if (selEnd in 0..totalLen) selEnd else totalLen |
|
|
|
|
|
|
|
|
startRecording() |
|
|
startRecording() |
|
|
} |
|
|
} |
|
|
@ -336,6 +378,16 @@ class MainActivity : AppCompatActivity() { |
|
|
} |
|
|
} |
|
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { |
|
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { |
|
|
if (isCurrentlyRecording) { |
|
|
if (isCurrentlyRecording) { |
|
|
|
|
|
// Update insertion point to where cursor was right before processing |
|
|
|
|
|
val selStart = binding.etTranscript.selectionStart |
|
|
|
|
|
val selEnd = binding.etTranscript.selectionEnd |
|
|
|
|
|
val currentText = binding.etTranscript.text?.toString() ?: "" |
|
|
|
|
|
val totalLen = currentText.length |
|
|
|
|
|
|
|
|
|
|
|
if (selStart in 0..totalLen && selEnd in 0..totalLen) { |
|
|
|
|
|
voiceInsertionCursorStart = selStart |
|
|
|
|
|
voiceInsertionCursorEnd = selEnd |
|
|
|
|
|
} |
|
|
stopRecordingAndProcess() |
|
|
stopRecordingAndProcess() |
|
|
} |
|
|
} |
|
|
true |
|
|
true |
|
|
@ -346,7 +398,11 @@ class MainActivity : AppCompatActivity() { |
|
|
|
|
|
|
|
|
private suspend fun sendDirectPaste(text: String, cursor: Int): Boolean { |
|
|
private suspend fun sendDirectPaste(text: String, cursor: Int): Boolean { |
|
|
return try { |
|
|
return try { |
|
|
val client = OkHttpClient() |
|
|
|
|
|
|
|
|
val client = OkHttpClient.Builder() |
|
|
|
|
|
.connectTimeout(2500, TimeUnit.MILLISECONDS) |
|
|
|
|
|
.writeTimeout(3000, TimeUnit.MILLISECONDS) |
|
|
|
|
|
.readTimeout(3000, TimeUnit.MILLISECONDS) |
|
|
|
|
|
.build() |
|
|
val json = JSONObject().apply { |
|
|
val json = JSONObject().apply { |
|
|
put("text", text) |
|
|
put("text", text) |
|
|
put("cursor_pos", cursor) |
|
|
put("cursor_pos", cursor) |
|
|
@ -354,13 +410,25 @@ class MainActivity : AppCompatActivity() { |
|
|
}.toString() |
|
|
}.toString() |
|
|
val mediaType = "application/json; charset=utf-8".toMediaType() |
|
|
val mediaType = "application/json; charset=utf-8".toMediaType() |
|
|
val body = json.toRequestBody(mediaType) |
|
|
val body = json.toRequestBody(mediaType) |
|
|
val req = Request.Builder() |
|
|
|
|
|
.url("http://$gatewayHost/paste") |
|
|
|
|
|
.post(body) |
|
|
|
|
|
.build() |
|
|
|
|
|
client.newCall(req).execute().use { resp -> |
|
|
|
|
|
resp.isSuccessful |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
val hosts = listOf(gatewayHost, "116.16.16.19:8999", "2.180.16.250:8999").distinct() |
|
|
|
|
|
var success = false |
|
|
|
|
|
for (h in hosts) { |
|
|
|
|
|
try { |
|
|
|
|
|
val req = Request.Builder() |
|
|
|
|
|
.url("http://$h/paste") |
|
|
|
|
|
.post(body) |
|
|
|
|
|
.build() |
|
|
|
|
|
client.newCall(req).execute().use { resp -> |
|
|
|
|
|
if (resp.isSuccessful) { |
|
|
|
|
|
success = true |
|
|
|
|
|
return@use |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
if (success) break |
|
|
|
|
|
} catch (_: Exception) {} |
|
|
} |
|
|
} |
|
|
|
|
|
success |
|
|
} catch (e: Exception) { |
|
|
} catch (e: Exception) { |
|
|
AppLogger.log("Main", "خطای ارسال دستی: ${e.message}") |
|
|
AppLogger.log("Main", "خطای ارسال دستی: ${e.message}") |
|
|
false |
|
|
false |
|
|
@ -456,12 +524,12 @@ class MainActivity : AppCompatActivity() { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private fun startPulseAnimation() { |
|
|
private fun startPulseAnimation() { |
|
|
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1.0f, 1.25f, 1.0f) |
|
|
|
|
|
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.0f, 1.25f, 1.0f) |
|
|
|
|
|
val alpha = PropertyValuesHolder.ofFloat(View.ALPHA, 0.3f, 0.7f, 0.3f) |
|
|
|
|
|
|
|
|
val scaleX = PropertyValuesHolder.ofFloat(View.SCALE_X, 1.0f, 1.18f, 1.0f) |
|
|
|
|
|
val scaleY = PropertyValuesHolder.ofFloat(View.SCALE_Y, 1.0f, 1.18f, 1.0f) |
|
|
|
|
|
val alpha = PropertyValuesHolder.ofFloat(View.ALPHA, 0.4f, 0.85f, 0.4f) |
|
|
|
|
|
|
|
|
pulseAnimator = ObjectAnimator.ofPropertyValuesHolder(binding.viewGlow, scaleX, scaleY, alpha).apply { |
|
|
pulseAnimator = ObjectAnimator.ofPropertyValuesHolder(binding.viewGlow, scaleX, scaleY, alpha).apply { |
|
|
duration = 1000 |
|
|
|
|
|
|
|
|
duration = 900 |
|
|
repeatCount = ValueAnimator.INFINITE |
|
|
repeatCount = ValueAnimator.INFINITE |
|
|
interpolator = AccelerateDecelerateInterpolator() |
|
|
interpolator = AccelerateDecelerateInterpolator() |
|
|
start() |
|
|
start() |
|
|
@ -472,7 +540,7 @@ class MainActivity : AppCompatActivity() { |
|
|
pulseAnimator?.cancel() |
|
|
pulseAnimator?.cancel() |
|
|
binding.viewGlow.scaleX = 1.0f |
|
|
binding.viewGlow.scaleX = 1.0f |
|
|
binding.viewGlow.scaleY = 1.0f |
|
|
binding.viewGlow.scaleY = 1.0f |
|
|
binding.viewGlow.alpha = 0.3f |
|
|
|
|
|
|
|
|
binding.viewGlow.alpha = 0.0f |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
private fun vibrate(durationMs: Long) { |
|
|
private fun vibrate(durationMs: Long) { |
|
|
|