feat: Video Recording Timer , Auto Skip . fix: Fake Snap Score

This commit is contained in:
RSR/
2026-03-14 20:05:59 +04:00
parent da8a261202
commit a27753d8ae
15 changed files with 651 additions and 96 deletions

View File

@@ -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": "يعرض تراكب مؤقت التسجيل عند تسجيل الفيديو"
}
}
},

View File

@@ -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"
}
}
},

View File

@@ -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+")) } }
}

View File

@@ -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()
}
}
}

View File

@@ -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)
)
)
}

View File

@@ -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"))

View File

@@ -154,6 +154,7 @@ class FeatureManager(
AutoDeleteSentMessages(),
FriendNotes(),
DoubleTapChatAction(),
VideoRecordTimer(),
SnapScoreChanges(),
DisableSnapModeRestrictions(),
MessageTranslator(),

View File

@@ -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())
}
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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) {

View File

@@ -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)
)
}

View File

@@ -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)

View File

@@ -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
)
}
}
}
}
}
}

View File

@@ -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
)
}
}
}
}

View File

@@ -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" }