feat: Video Recording Timer , Auto Skip . fix: Fake Snap Score
This commit is contained in:
@@ -1108,6 +1108,10 @@
|
||||
"name": "مؤشر مصدر القصة",
|
||||
"description": "يعرض أيقونة تشير إلى ما إذا كان السناب تم التقاطه من الكاميرا أو رفعه من المعرض\nيعمل فقط مع قصص الأصدقاء"
|
||||
},
|
||||
"story_snap_jump": {
|
||||
"name": "تخطي تلقائي",
|
||||
"description": "يضيف زر تخطي للانتقال إلى أي سناب في القصة. اضغط على أيقونة التخطي أو العداد لفتح نافذة القفز"
|
||||
},
|
||||
"old_bitmoji_selfie": {
|
||||
"name": "سيلفي Bitmoji القديم",
|
||||
"description": "يعيد سيلفي Bitmoji من إصدارات Snapchat القديمة"
|
||||
@@ -1158,7 +1162,7 @@
|
||||
},
|
||||
"spoof_snap_score": {
|
||||
"name": "تزييف نقاط سناب شات",
|
||||
"description": "تزييف نقاط السناب شات الخاصة بك (محلياً فقط)",
|
||||
"description": "يقوم بتزييف عدد نقاط سناب شات (المحلية فقط)",
|
||||
"properties": {
|
||||
"custom_snap_score": {
|
||||
"name": "النقاط المخصصة",
|
||||
@@ -1934,6 +1938,10 @@
|
||||
"hevc_recording": {
|
||||
"name": "تسجيل HEVC",
|
||||
"description": "يستخدم ترميز HEVC (H.265) لتسجيل الفيديو"
|
||||
},
|
||||
"video_record_timer": {
|
||||
"name": "مؤقت تسجيل الفيديو",
|
||||
"description": "يعرض تراكب مؤقت التسجيل عند تسجيل الفيديو"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1149,6 +1149,10 @@
|
||||
"name": "Story Source Indicator",
|
||||
"description": "Shows an icon indicating whether the snap was taken from the camera or uploaded from the gallery\nOnly works with friend stories"
|
||||
},
|
||||
"story_snap_jump": {
|
||||
"name": "Auto Skip",
|
||||
"description": "Adds a skip button to jump to any snap in a story. Tap the skip icon or counter to open the jump dialog"
|
||||
},
|
||||
"old_bitmoji_selfie": {
|
||||
"name": "Old Bitmoji Selfie",
|
||||
"description": "Brings back the Bitmoji selfies from older Snapchat versions"
|
||||
@@ -1199,7 +1203,7 @@
|
||||
},
|
||||
"spoof_snap_score": {
|
||||
"name": "Spoof Snap Score",
|
||||
"description": "Spoof your Snap Score (locally)",
|
||||
"description": "Spoof your Snap Score (local only)",
|
||||
"properties": {
|
||||
"custom_snap_score": {
|
||||
"name": "Custom Snap Score",
|
||||
@@ -1975,6 +1979,10 @@
|
||||
"hevc_recording": {
|
||||
"name": "HEVC Recording",
|
||||
"description": "Uses HEVC (H.265) codec for video recording"
|
||||
},
|
||||
"video_record_timer": {
|
||||
"name": "Video Recording Timer",
|
||||
"description": "Shows a recording timer overlay when recording video"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -57,6 +57,7 @@ class Camera : ConfigContainer() {
|
||||
val startupDefaultCamera = unique("startup_default_camera", "front", "back") { requireRestart() }
|
||||
val overrideFrontResolution get() = _overrideFrontResolution
|
||||
val overrideBackResolution get() = _overrideBackResolution
|
||||
val videoRecordTimer = boolean("video_record_timer")
|
||||
|
||||
val customResolution = string("custom_resolution") { addNotices(FeatureNotice.UNSTABLE); inputCheck = { it.matches(Regex("\\d+x\\d+")) } }
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
val operaMediaQuickInfo = boolean("opera_media_quick_info") { requireRestart() }
|
||||
val storyCounter = boolean("story_counter") { requireRestart() }
|
||||
val storySourceIndicator = boolean("story_source_indicator") { requireRestart() }
|
||||
val storySnapJump = boolean("story_snap_jump") { requireRestart() }
|
||||
val oldBitmojiSelfie = unique("old_bitmoji_selfie", "2d", "3d") { requireCleanCache() }
|
||||
val disableSpotlight = boolean("disable_spotlight") { requireRestart() }
|
||||
val verticalStoryViewer = boolean("vertical_story_viewer") { requireRestart() }
|
||||
@@ -68,24 +69,7 @@ class UserInterfaceTweaks : ConfigContainer() {
|
||||
requireRestart()
|
||||
inputCheck = { input ->
|
||||
if (input.isEmpty()) true
|
||||
else {
|
||||
val digits = input.replace(Regex("[^0-9]"), "")
|
||||
val isTooLong = digits.isNotEmpty() && (digits.length > 7 || digits.toLong() > 9999999L)
|
||||
if (isTooLong) {
|
||||
try {
|
||||
val context = Class.forName("android.app.ActivityThread").getMethod("currentApplication").invoke(null) as? android.content.Context
|
||||
context?.let {
|
||||
val handler = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
handler.post {
|
||||
android.widget.Toast.makeText(it, "The maximum Snap Score limit is 9,999,999", android.widget.Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
} catch (_: Throwable) {}
|
||||
false
|
||||
} else {
|
||||
digits.isNotEmpty()
|
||||
}
|
||||
}
|
||||
else input.replace(Regex("[^0-9]"), "").isNotEmpty()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package me.eternal.purrfectsnap.common.ui
|
||||
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
/**
|
||||
* Shared palette for PurrfectSnap overlay UI (dialogs, story overlays) shown inside Snapchat.
|
||||
* Matches the PurrfectSnap manager app's premium look. Used by core module.
|
||||
*/
|
||||
object PurrfectOverlayPalette {
|
||||
val glowPrimary = Color(0xFF8C7BFF)
|
||||
val glowSecondary = Color(0xFF5FD8FF)
|
||||
val textPrimary = Color.White
|
||||
val textSecondary = Color(0xFFD9D3FF)
|
||||
val cardOverlayColor = Color(0xFF2A2452).copy(alpha = 0.95f)
|
||||
val cardOverlay = Brush.linearGradient(
|
||||
listOf(
|
||||
Color(0xFF2A2452).copy(alpha = 0.95f),
|
||||
Color(0xFF1A143A).copy(alpha = 0.92f)
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -34,6 +34,8 @@ dependencies {
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.androidx.documentfile)
|
||||
implementation(libs.rhino)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
|
||||
|
||||
implementation(project(":common"))
|
||||
implementation(project(":mapper"))
|
||||
|
||||
@@ -154,6 +154,7 @@ class FeatureManager(
|
||||
AutoDeleteSentMessages(),
|
||||
FriendNotes(),
|
||||
DoubleTapChatAction(),
|
||||
VideoRecordTimer(),
|
||||
SnapScoreChanges(),
|
||||
DisableSnapModeRestrictions(),
|
||||
MessageTranslator(),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.tweaks
|
||||
|
||||
import android.media.AudioRecord
|
||||
import android.media.MediaCodec
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
|
||||
class VideoRecordTimer : Feature("Video Record Timer") {
|
||||
override fun init() {
|
||||
if (!context.config.camera.videoRecordTimer.get()) return
|
||||
|
||||
val activeComponents = mutableSetOf<Int>()
|
||||
|
||||
fun startRecording(componentHashCode: Int) {
|
||||
synchronized(activeComponents) {
|
||||
if (activeComponents.isEmpty()) {
|
||||
context.inAppOverlay.videoRecordTimerState.isRecording = true
|
||||
context.inAppOverlay.videoRecordTimerState.recordingStartTime = System.currentTimeMillis() - 1000
|
||||
}
|
||||
activeComponents.add(componentHashCode)
|
||||
}
|
||||
}
|
||||
|
||||
fun stopRecording(componentHashCode: Int) {
|
||||
synchronized(activeComponents) {
|
||||
activeComponents.remove(componentHashCode)
|
||||
if (activeComponents.isEmpty()) {
|
||||
context.inAppOverlay.videoRecordTimerState.isRecording = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AudioRecord::class.java.hook("startRecording", HookStage.AFTER) {
|
||||
startRecording(it.thisObject<AudioRecord>().hashCode())
|
||||
}
|
||||
|
||||
AudioRecord::class.java.hook("stop", HookStage.BEFORE) {
|
||||
stopRecording(it.thisObject<AudioRecord>().hashCode())
|
||||
}
|
||||
|
||||
AudioRecord::class.java.hook("release", HookStage.BEFORE) {
|
||||
stopRecording(it.thisObject<AudioRecord>().hashCode())
|
||||
}
|
||||
|
||||
MediaCodec::class.java.hook("start", HookStage.AFTER) {
|
||||
val codecName = runCatching { it.thisObject<MediaCodec>().name }.getOrNull() ?: ""
|
||||
if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) {
|
||||
startRecording(it.thisObject<MediaCodec>().hashCode())
|
||||
}
|
||||
}
|
||||
|
||||
MediaCodec::class.java.hook("stop", HookStage.BEFORE) {
|
||||
val codecName = runCatching { it.thisObject<MediaCodec>().name }.getOrNull() ?: ""
|
||||
if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) {
|
||||
stopRecording(it.thisObject<MediaCodec>().hashCode())
|
||||
}
|
||||
}
|
||||
|
||||
MediaCodec::class.java.hook("release", HookStage.BEFORE) {
|
||||
val codecName = runCatching { it.thisObject<MediaCodec>().name }.getOrNull() ?: ""
|
||||
if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) {
|
||||
stopRecording(it.thisObject<MediaCodec>().hashCode())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,31 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.view.ViewGroup
|
||||
import android.widget.TextView
|
||||
import androidx.constraintlayout.widget.ConstraintLayout
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.ui.getValdiContext
|
||||
import me.eternal.purrfectsnap.core.ui.getValdiViewNode
|
||||
import me.eternal.purrfectsnap.core.util.hook.HookStage
|
||||
import me.eternal.purrfectsnap.core.util.hook.hook
|
||||
import me.eternal.purrfectsnap.core.wrapper.impl.valdi.ValdiViewNode
|
||||
|
||||
class FakeSnapScore : Feature("Fake Snap Score") {
|
||||
|
||||
private fun findAllSnapTextViewsRecursive(node: ValdiViewNode, depth: Int = 0, result: MutableList<ValdiViewNode> = mutableListOf()): List<ValdiViewNode> {
|
||||
if (depth > 15) return result
|
||||
if (node.getClassName().endsWith("SnapTextView")) result.add(node)
|
||||
for (child in node.getChildren()) {
|
||||
findAllSnapTextViewsRecursive(child, depth + 1, result)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
override fun init() {
|
||||
if (context.config.userInterface.spoofSnapScore.globalState != true) return
|
||||
|
||||
val customScoreRaw = context.config.userInterface.spoofSnapScore.customSnapScore.getNullable()?.trim() ?: return
|
||||
val customScoreRaw = context.config.userInterface.spoofSnapScore.customSnapScore.getNullable()?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: return
|
||||
|
||||
val customScore = try {
|
||||
val digitsOnly = customScoreRaw.replace(Regex("[^0-9]"), "")
|
||||
if (digitsOnly.isNotEmpty()) {
|
||||
@@ -32,57 +46,67 @@ class FakeSnapScore : Feature("Fake Snap Score") {
|
||||
null
|
||||
} ?: return
|
||||
|
||||
// Approach 1: AddViewEvent + Valdi setAttribute (score dialog when tapping pill)
|
||||
context.event.subscribe(AddViewEvent::class) { event ->
|
||||
if (event.viewClassName.endsWith("ProfileFlatlandmySnapScoreIdentityPillDialogView")) {
|
||||
event.view.post {
|
||||
event.view.getValdiContext()?.enqueueNextRenderCallback {
|
||||
val rootNode = event.view.getValdiViewNode() ?: return@enqueueNextRenderCallback
|
||||
val snapTextViews = findAllSnapTextViewsRecursive(rootNode)
|
||||
// Only spoof the blue pill (2nd), white shows original score
|
||||
snapTextViews.getOrNull(1)?.setAttribute("value", customScore)
|
||||
event.view.postInvalidate()
|
||||
}
|
||||
event.view.postInvalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Approach 2: TextView.setText hook as fallback - text change only, no layout modifications
|
||||
onNextActivityCreate {
|
||||
TextView::class.java.hook("setText", HookStage.BEFORE) { param ->
|
||||
val text = param.argNullable<CharSequence>(0)?.toString() ?: return@hook
|
||||
|
||||
if (text.matches(Regex("^[0-9\\s,.]+$"))) {
|
||||
val digits = text.replace(Regex("[^0-9]"), "")
|
||||
|
||||
if (digits.length >= 4 || text.contains(",")) {
|
||||
val textView = param.thisObject() as TextView
|
||||
var parent = textView.parent
|
||||
var isProfile = false
|
||||
|
||||
while (parent != null) {
|
||||
val className = parent.javaClass.simpleName.lowercase()
|
||||
if (className.contains("profile") || className.contains("identity")) {
|
||||
isProfile = true
|
||||
break
|
||||
}
|
||||
parent = parent.parent
|
||||
}
|
||||
if (!text.matches(Regex("^[0-9\\s,.]+$"))) return@hook
|
||||
|
||||
if (isProfile) {
|
||||
param.setArg(0, customScore)
|
||||
|
||||
textView.post {
|
||||
textView.layoutParams?.let { lp ->
|
||||
if (lp.width != ViewGroup.LayoutParams.WRAP_CONTENT) {
|
||||
lp.width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
textView.layoutParams = lp
|
||||
}
|
||||
}
|
||||
|
||||
val paint = textView.paint
|
||||
val textWidth = paint.measureText(customScore).toInt() + textView.paddingLeft + textView.paddingRight
|
||||
if (textView.minWidth < textWidth) {
|
||||
textView.minWidth = textWidth
|
||||
}
|
||||
val digits = text.replace(Regex("[^0-9]"), "")
|
||||
if (digits.length < 4 && !text.contains(",")) return@hook
|
||||
|
||||
var currentParent = textView.parent
|
||||
while (currentParent is ViewGroup) {
|
||||
if (currentParent is ConstraintLayout) {
|
||||
currentParent.layoutParams?.let { plp ->
|
||||
if (plp.width != ViewGroup.LayoutParams.WRAP_CONTENT) {
|
||||
plp.width = ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
currentParent.layoutParams = plp
|
||||
}
|
||||
}
|
||||
}
|
||||
currentParent = currentParent.parent
|
||||
}
|
||||
val textView = param.thisObject() as TextView
|
||||
var parent = textView.parent
|
||||
var isMyProfile = false
|
||||
var isFriendContext = false
|
||||
var isInScoreDialog = false
|
||||
|
||||
while (parent != null) {
|
||||
val fullName = parent.javaClass.name.lowercase()
|
||||
if (fullName.contains("friendsnapscore") || fullName.contains("friendprofile")) {
|
||||
isFriendContext = true
|
||||
break
|
||||
}
|
||||
if (fullName.contains("mysnapscore") || fullName.contains("myprofile")) {
|
||||
isMyProfile = true
|
||||
}
|
||||
if (fullName.contains("mysnapscoreidentitypilldialog")) isInScoreDialog = true
|
||||
parent = parent.parent
|
||||
}
|
||||
|
||||
// Only spoof blue pill in profile (not in dialog - AddViewEvent handles that). White always shows original.
|
||||
if (isMyProfile && !isFriendContext && !isInScoreDialog) {
|
||||
param.setArg(0, customScore)
|
||||
// Prevent ellipsis (...) when score is large - fix TextView and parent TextViews (white + blue pill)
|
||||
textView.post {
|
||||
val minW = textView.paint.measureText(customScore).toInt() + 80
|
||||
var current: android.view.View? = textView
|
||||
for (i in 0..4) {
|
||||
if (current == null) break
|
||||
if (current is TextView) {
|
||||
current.ellipsize = null
|
||||
current.maxWidth = Int.MAX_VALUE
|
||||
current.minWidth = minW
|
||||
current.minimumWidth = minW
|
||||
}
|
||||
current = current.parent as? android.view.View
|
||||
current?.requestLayout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,8 +41,12 @@ class OperaStoryCounter : Feature("OperaStoryCounter") {
|
||||
override fun init() {
|
||||
val showCounter = this@OperaStoryCounter.context.config.userInterface.storyCounter.get()
|
||||
val showSourceIndicator = this@OperaStoryCounter.context.config.userInterface.storySourceIndicator.get()
|
||||
val storySnapJump = this@OperaStoryCounter.context.config.userInterface.storySnapJump.get()
|
||||
val storySnapListDownload = this@OperaStoryCounter.context.config.downloader.storySnapListDownload.get()
|
||||
val operaDownloadButton = this@OperaStoryCounter.context.config.downloader.operaDownloadButton.get()
|
||||
|
||||
if (!showCounter && !showSourceIndicator) return
|
||||
// OperaStoryOverlay handles counter/source/jump when any of these are enabled
|
||||
if (showCounter || showSourceIndicator || storySnapJump || storySnapListDownload || operaDownloadButton) return
|
||||
|
||||
this@OperaStoryCounter.context.event.subscribe(AddViewEvent::class) { event ->
|
||||
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import android.view.Gravity
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.CameraAlt
|
||||
import androidx.compose.material.icons.outlined.Download
|
||||
import androidx.compose.material.icons.outlined.PhotoLibrary
|
||||
import androidx.compose.material.icons.outlined.SkipNext
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import me.eternal.purrfectsnap.common.ui.createComposeView
|
||||
import me.eternal.purrfectsnap.core.event.events.impl.AddViewEvent
|
||||
import me.eternal.purrfectsnap.core.features.Feature
|
||||
import me.eternal.purrfectsnap.core.features.impl.downloader.MediaDownloader
|
||||
import me.eternal.purrfectsnap.core.ui.children
|
||||
|
||||
import java.lang.ref.WeakReference
|
||||
|
||||
/**
|
||||
* Provides snap jump logic for Story Snap List Download batch downloads.
|
||||
* Initializes when storySnapListDownload is enabled to enable programmatic navigation between snaps.
|
||||
* Main overlay feature for story counter, source indicator, and Auto Skip (snap jump).
|
||||
* Also provides snap jump logic for Story Snap List Download batch downloads.
|
||||
*/
|
||||
class OperaStoryOverlay : Feature("OperaStoryOverlay") {
|
||||
private val overlayState = OperaStoryOverlayState()
|
||||
@@ -18,36 +51,173 @@ class OperaStoryOverlay : Feature("OperaStoryOverlay") {
|
||||
private lateinit var snapJump: OperaStorySnapJump
|
||||
|
||||
override fun init() {
|
||||
val showCounter = context.config.userInterface.storyCounter.get()
|
||||
val showSourceIndicator = context.config.userInterface.storySourceIndicator.get()
|
||||
val enableSnapJump = context.config.userInterface.storySnapJump.get()
|
||||
val storySnapListDownload = context.config.downloader.storySnapListDownload.get()
|
||||
val showDownloadButton = context.config.downloader.operaDownloadButton.get()
|
||||
|
||||
if (!storySnapListDownload) return
|
||||
if (!showCounter && !showSourceIndicator && !enableSnapJump && !storySnapListDownload && !showDownloadButton) return
|
||||
|
||||
snapJump = OperaStorySnapJump(context, overlayState) { storyFrameLayout.get() }
|
||||
|
||||
context.event.subscribe(AddViewEvent::class) { event ->
|
||||
if (event.view is FrameLayout && event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
|
||||
val viewGroup = event.view as FrameLayout
|
||||
if (event.parent.javaClass.superclass?.name?.endsWith("OpenLayout") == true) {
|
||||
val viewGroup = event.view as? ViewGroup ?: return@subscribe
|
||||
|
||||
val isWrapped = viewGroup is FrameLayout && viewGroup.childCount == 1 && viewGroup.getChildAt(0) is ViewGroup
|
||||
val actualLayer = if (isWrapped) viewGroup.getChildAt(0) as ViewGroup else viewGroup
|
||||
|
||||
if (viewGroup.findViewWithTag<View>("story_counter") != null ||
|
||||
event.parent.findViewWithTag<View>("story_counter") != null) return@subscribe
|
||||
event.parent.findViewWithTag<View>("story_counter") != null ||
|
||||
actualLayer.javaClass.name.endsWith("ScalableCircleMaskFrameLayout")
|
||||
) return@subscribe
|
||||
|
||||
if (event.parent.children().none { it.javaClass.name.endsWith("ScalableCircleMaskFrameLayout") }) return@subscribe
|
||||
if (actualLayer.childCount > 0 && !actualLayer.javaClass.name.contains("OperaShapeView")) {
|
||||
storyFrameLayout = WeakReference(viewGroup as FrameLayout)
|
||||
viewGroup.tag = "story_counter"
|
||||
|
||||
storyFrameLayout = WeakReference(viewGroup)
|
||||
val composeView = createComposeView(viewGroup.context) {
|
||||
val counterText = overlayState.counterState.value
|
||||
val source = overlayState.sourceState.value
|
||||
val hasCounter = showCounter && counterText.isNotEmpty()
|
||||
val hasSource = showSourceIndicator && source.isNotEmpty()
|
||||
val currentIdx = overlayState.currentIndexState.intValue
|
||||
val totalCount = overlayState.totalCountState.intValue
|
||||
var showJumpDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val isDownloadButtonEnabled = context.config.downloader.operaDownloadButton.get()
|
||||
|
||||
if (hasCounter || hasSource || enableSnapJump || isDownloadButtonEnabled) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.End
|
||||
) {
|
||||
if (hasCounter || hasSource || (enableSnapJump && totalCount > 1)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = Color(0x4C000000),
|
||||
shape = CircleShape
|
||||
)
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
if (hasCounter) {
|
||||
OperaStoryCounterDisplay(
|
||||
counterText = counterText,
|
||||
enableSnapJump = enableSnapJump,
|
||||
totalCount = totalCount,
|
||||
onCounterClick = { showJumpDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
if (enableSnapJump && (hasCounter || totalCount > 1) && totalCount > 1) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(10.dp)
|
||||
.background(Color.White.copy(alpha = 0.4f))
|
||||
)
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.SkipNext,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.size(14.dp)
|
||||
.clickable { showJumpDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
if (hasSource) {
|
||||
if (hasCounter || (enableSnapJump && totalCount > 1)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(1.dp)
|
||||
.height(10.dp)
|
||||
.background(Color.White.copy(alpha = 0.4f))
|
||||
)
|
||||
}
|
||||
OperaStorySourceIndicatorDisplay(source = source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isDownloadButtonEnabled) {
|
||||
val mediaDownloader = remember { context.feature(MediaDownloader::class) }
|
||||
val snapSource = overlayState.snapSourceState.value
|
||||
val isInConversation = overlayState.isInConversationState.value
|
||||
if (snapSource != "SINGLE_SNAP_STORY" && snapSource != "SPOTLIGHT" && snapSource != "PUBLIC_STORY" && !isInConversation) {
|
||||
if (hasCounter || hasSource || (enableSnapJump && totalCount > 1)) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.background(
|
||||
color = Color(0x4C000000),
|
||||
shape = CircleShape
|
||||
)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Download,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.padding(6.dp)
|
||||
.size(18.dp)
|
||||
.clickable {
|
||||
mediaDownloader.downloadLastOperaMediaAsync(allowDuplicate = false)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (enableSnapJump && showJumpDialog && totalCount > 1) {
|
||||
OperaStorySnapJumpDialog(
|
||||
currentIndex = currentIdx,
|
||||
totalCount = totalCount,
|
||||
onDismiss = { showJumpDialog = false },
|
||||
onJump = { targetIndex ->
|
||||
showJumpDialog = false
|
||||
snapJump.jumpToSnap(targetIndex)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
composeView.tag = "story_counter"
|
||||
composeView.layoutParams = FrameLayout.LayoutParams(
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT,
|
||||
FrameLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
gravity = Gravity.TOP or Gravity.END
|
||||
topMargin = this@OperaStoryOverlay.context.userInterface.dpToPx(50)
|
||||
marginEnd = this@OperaStoryOverlay.context.userInterface.dpToPx(10)
|
||||
}
|
||||
viewGroup.addView(composeView)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onNextActivityCreate {
|
||||
overlayState.setupDisplayStateHook(
|
||||
context = context,
|
||||
showCounter = false,
|
||||
showSourceIndicator = false,
|
||||
onSnapFullyDisplayed = {
|
||||
if (snapJump.isJumping()) {
|
||||
snapJump.onSnapFullyDisplayed(it)
|
||||
showCounter = showCounter,
|
||||
showSourceIndicator = showSourceIndicator,
|
||||
onSnapFullyDisplayed = if (enableSnapJump) {
|
||||
{ currentIndex ->
|
||||
if (snapJump.isJumping()) {
|
||||
snapJump.onSnapFullyDisplayed(currentIndex)
|
||||
}
|
||||
}
|
||||
},
|
||||
onClearState = { snapJump.removeJumpOverlay() }
|
||||
} else null,
|
||||
onClearState = if (enableSnapJump) { { snapJump.removeJumpOverlay() } } else null
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -55,3 +225,46 @@ class OperaStoryOverlay : Feature("OperaStoryOverlay") {
|
||||
fun requestJumpToSnap(targetIndex: Int, totalCountOverride: Int? = null): Boolean =
|
||||
snapJump.requestJumpToSnap(targetIndex, totalCountOverride)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OperaStoryCounterDisplay(
|
||||
counterText: String,
|
||||
enableSnapJump: Boolean,
|
||||
totalCount: Int,
|
||||
onCounterClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (counterText.isEmpty()) return
|
||||
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier.then(
|
||||
if (enableSnapJump && totalCount > 1)
|
||||
Modifier.clickable { onCounterClick() }
|
||||
else Modifier
|
||||
)
|
||||
) {
|
||||
Text(
|
||||
text = counterText,
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun OperaStorySourceIndicatorDisplay(
|
||||
source: String,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
if (source.isEmpty()) return
|
||||
|
||||
val icon = if (source == "CAMERA") Icons.Outlined.CameraAlt else Icons.Outlined.PhotoLibrary
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = modifier.size(11.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ class OperaStoryOverlayState {
|
||||
val sourceState = mutableStateOf("")
|
||||
val currentIndexState = mutableIntStateOf(-1)
|
||||
val totalCountState = mutableIntStateOf(0)
|
||||
val snapSourceState = mutableStateOf<String?>(null)
|
||||
val isInConversationState = mutableStateOf(false)
|
||||
|
||||
fun setupDisplayStateHook(
|
||||
context: ModContext,
|
||||
@@ -44,23 +46,14 @@ class OperaStoryOverlayState {
|
||||
val mediaParamMap: ParamMap = operaLayerList.map { Layer(it) }.first().paramMap
|
||||
val snapSource = mediaParamMap["SNAP_SOURCE"]?.toString()
|
||||
|
||||
if (mediaParamMap.containsKey("MESSAGE_ID")) {
|
||||
context.runOnUiThread {
|
||||
counterState.value = ""
|
||||
sourceState.value = ""
|
||||
currentIndexState.intValue = -1
|
||||
totalCountState.intValue = 0
|
||||
onClearState?.invoke()
|
||||
}
|
||||
return@hook
|
||||
}
|
||||
|
||||
if (snapSource == "SINGLE_SNAP_STORY") {
|
||||
if (mediaParamMap.containsKey("MESSAGE_ID") || snapSource == "SINGLE_SNAP_STORY") {
|
||||
context.runOnUiThread {
|
||||
counterState.value = ""
|
||||
sourceState.value = ""
|
||||
currentIndexState.intValue = -1
|
||||
totalCountState.intValue = 0
|
||||
snapSourceState.value = snapSource
|
||||
isInConversationState.value = mediaParamMap.containsKey("MESSAGE_ID")
|
||||
onClearState?.invoke()
|
||||
}
|
||||
return@hook
|
||||
@@ -86,6 +79,8 @@ class OperaStoryOverlayState {
|
||||
sourceState.value = mediaOrigin
|
||||
currentIndexState.intValue = currentIndex ?: -1
|
||||
totalCountState.intValue = totalCount ?: 0
|
||||
snapSourceState.value = snapSource
|
||||
isInConversationState.value = false
|
||||
|
||||
onSnapFullyDisplayed?.let { callback ->
|
||||
if (currentIndex != null) callback(currentIndex)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
package me.eternal.purrfectsnap.core.features.impl.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Slider
|
||||
import androidx.compose.material3.SliderDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import me.eternal.purrfectsnap.common.ui.PurrfectOverlayPalette
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Composable dialog for jumping to a specific snap in the story (Auto Skip).
|
||||
* Matches SnapEnhance dialog size (75% width), transparency (0.88), and layout.
|
||||
* Styled with PurrfectSnap colors.
|
||||
*/
|
||||
@Composable
|
||||
fun OperaStorySnapJumpDialog(
|
||||
currentIndex: Int,
|
||||
totalCount: Int,
|
||||
onDismiss: () -> Unit,
|
||||
onJump: (Int) -> Unit
|
||||
) {
|
||||
var sliderValue by remember { mutableFloatStateOf((currentIndex + 1).toFloat()) }
|
||||
val selectedSnap = sliderValue.roundToInt()
|
||||
|
||||
Dialog(onDismissRequest = onDismiss) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.75f)
|
||||
.background(
|
||||
color = PurrfectOverlayPalette.cardOverlayColor.copy(alpha = 0.88f),
|
||||
shape = RoundedCornerShape(24.dp)
|
||||
)
|
||||
.padding(20.dp)
|
||||
) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.Bottom,
|
||||
horizontalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "$selectedSnap",
|
||||
fontSize = 32.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = PurrfectOverlayPalette.textPrimary
|
||||
)
|
||||
Text(
|
||||
text = " / $totalCount",
|
||||
fontSize = 14.sp,
|
||||
color = PurrfectOverlayPalette.textSecondary,
|
||||
modifier = Modifier.padding(bottom = 5.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
Slider(
|
||||
value = sliderValue,
|
||||
onValueChange = { sliderValue = it },
|
||||
valueRange = 1f..totalCount.toFloat(),
|
||||
steps = if (totalCount > 2) totalCount - 2 else 0,
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = PurrfectOverlayPalette.glowPrimary,
|
||||
activeTrackColor = PurrfectOverlayPalette.glowPrimary,
|
||||
activeTickColor = Color.Transparent,
|
||||
inactiveTrackColor = PurrfectOverlayPalette.textPrimary.copy(alpha = 0.12f),
|
||||
inactiveTickColor = Color.Transparent
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(
|
||||
color = Color.White.copy(alpha = 0.12f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.clickable { onDismiss() }
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
fontSize = 13.sp,
|
||||
color = PurrfectOverlayPalette.textSecondary
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.background(
|
||||
color = PurrfectOverlayPalette.glowPrimary.copy(alpha = 0.9f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
)
|
||||
.clickable {
|
||||
onDismiss()
|
||||
onJump(selectedSnap - 1)
|
||||
}
|
||||
.padding(vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Go",
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,10 +63,16 @@ class CallRecorderUIState {
|
||||
var lastInteractionTime by mutableStateOf(0L)
|
||||
}
|
||||
|
||||
class VideoRecordTimerState {
|
||||
var isRecording by mutableStateOf(false)
|
||||
var recordingStartTime by mutableStateOf(0L)
|
||||
}
|
||||
|
||||
class InAppOverlay(
|
||||
private val context: ModContext
|
||||
) {
|
||||
val callRecorderState = CallRecorderUIState()
|
||||
val videoRecordTimerState = VideoRecordTimerState()
|
||||
companion object {
|
||||
fun showCrashOverlay(content: String, throwable: Throwable? = null) {
|
||||
// deny network requests
|
||||
@@ -243,6 +249,79 @@ class InAppOverlay(
|
||||
}
|
||||
|
||||
CallRecorderOverlay()
|
||||
VideoRecordTimerOverlay()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun VideoRecordTimerOverlay() {
|
||||
var elapsedTime by remember { mutableStateOf(0L) }
|
||||
|
||||
LaunchedEffect(videoRecordTimerState.isRecording) {
|
||||
if (videoRecordTimerState.isRecording) {
|
||||
while (videoRecordTimerState.isRecording) {
|
||||
delay(100)
|
||||
elapsedTime = System.currentTimeMillis() - videoRecordTimerState.recordingStartTime
|
||||
}
|
||||
} else {
|
||||
elapsedTime = 0L
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = videoRecordTimerState.isRecording,
|
||||
enter = fadeIn(animationSpec = tween(300)) + scaleIn(
|
||||
initialScale = 0.8f,
|
||||
animationSpec = spring(
|
||||
dampingRatio = Spring.DampingRatioMediumBouncy,
|
||||
stiffness = Spring.StiffnessLow
|
||||
)
|
||||
),
|
||||
exit = fadeOut(animationSpec = tween(200)) + scaleOut(
|
||||
targetScale = 0.8f,
|
||||
animationSpec = tween(200)
|
||||
)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 45.dp),
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
val seconds = (elapsedTime / 1000) % 60
|
||||
val minutes = (elapsedTime / 1000) / 60
|
||||
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "pulse")
|
||||
val pulseRatio by infiniteTransition.animateFloat(
|
||||
initialValue = 0.2f,
|
||||
targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(1000, easing = LinearOutSlowInEasing),
|
||||
repeatMode = RepeatMode.Reverse
|
||||
),
|
||||
label = "pulseRatio"
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(Color.Black.copy(alpha = 0.5f), CircleShape)
|
||||
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.background(Color.Red.copy(alpha = pulseRatio), CircleShape)
|
||||
)
|
||||
Text(
|
||||
text = String.format("%02d:%02d", minutes, seconds),
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 17.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,8 @@ workmanager = "2.10.4"
|
||||
fetch = "3.4.1"
|
||||
yukihookapi = "1.3.1"
|
||||
kavaref = "1.0.2"
|
||||
constraintlayout = "2.2.1"
|
||||
|
||||
|
||||
[libraries]
|
||||
accompanist-navigation-animation = { module = "com.google.accompanist:accompanist-navigation-animation", version.ref = "accompanist" }
|
||||
@@ -39,6 +41,8 @@ androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx"
|
||||
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "compose-bom" }
|
||||
androidx-activity-ktx = { group = "androidx.activity", name = "activity-ktx", version.ref = "activity-ktx" }
|
||||
androidx-documentfile = { group = "androidx.documentfile", name = "documentfile", version.ref = "androidx-documentfile" }
|
||||
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
|
||||
|
||||
androidx-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" }
|
||||
androidx-material-icons-core = { module = "androidx.compose.material:material-icons-core" }
|
||||
androidx-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
|
||||
|
||||
Reference in New Issue
Block a user