From a27753d8aeb1f6cad4f97143a6c474c46624a77d Mon Sep 17 00:00:00 2001 From: RSR/ <52901789+RSR1337@users.noreply.github.com> Date: Sat, 14 Mar 2026 20:05:59 +0400 Subject: [PATCH] feat: Video Recording Timer , Auto Skip . fix: Fake Snap Score --- common/src/main/assets/lang/ar_AE.json | 10 +- common/src/main/assets/lang/en_US.json | 10 +- .../purrfectsnap/common/config/impl/Camera.kt | 1 + .../common/config/impl/UserInterfaceTweaks.kt | 20 +- .../common/ui/PurrfectOverlayPalette.kt | 22 ++ core/build.gradle.kts | 2 + .../core/features/FeatureManager.kt | 1 + .../features/impl/tweaks/VideoRecordTimer.kt | 67 +++++ .../core/features/impl/ui/FakeSnapScore.kt | 120 +++++---- .../features/impl/ui/OperaStoryCounter.kt | 6 +- .../features/impl/ui/OperaStoryOverlay.kt | 243 ++++++++++++++++-- .../impl/ui/OperaStoryOverlayState.kt | 19 +- .../impl/ui/OperaStorySnapJumpDialog.kt | 143 +++++++++++ .../purrfectsnap/core/ui/InAppOverlay.kt | 79 ++++++ gradle/libs.versions.toml | 4 + 15 files changed, 651 insertions(+), 96 deletions(-) create mode 100644 common/src/main/kotlin/me/eternal/purrfectsnap/common/ui/PurrfectOverlayPalette.kt create mode 100644 core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/VideoRecordTimer.kt create mode 100644 core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJumpDialog.kt diff --git a/common/src/main/assets/lang/ar_AE.json b/common/src/main/assets/lang/ar_AE.json index 04951e75..77c0e964 100644 --- a/common/src/main/assets/lang/ar_AE.json +++ b/common/src/main/assets/lang/ar_AE.json @@ -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": "يعرض تراكب مؤقت التسجيل عند تسجيل الفيديو" } } }, diff --git a/common/src/main/assets/lang/en_US.json b/common/src/main/assets/lang/en_US.json index a8418be8..de783c20 100644 --- a/common/src/main/assets/lang/en_US.json +++ b/common/src/main/assets/lang/en_US.json @@ -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" } } }, diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Camera.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Camera.kt index 40f44691..a71db652 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Camera.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/Camera.kt @@ -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+")) } } } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt index e3b1e4d5..c0bcdd0a 100644 --- a/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/config/impl/UserInterfaceTweaks.kt @@ -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() } } } diff --git a/common/src/main/kotlin/me/eternal/purrfectsnap/common/ui/PurrfectOverlayPalette.kt b/common/src/main/kotlin/me/eternal/purrfectsnap/common/ui/PurrfectOverlayPalette.kt new file mode 100644 index 00000000..e6b732b1 --- /dev/null +++ b/common/src/main/kotlin/me/eternal/purrfectsnap/common/ui/PurrfectOverlayPalette.kt @@ -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) + ) + ) +} diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 8f1b585d..157b0655 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -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")) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt index 5efd79ea..0337d61e 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/FeatureManager.kt @@ -154,6 +154,7 @@ class FeatureManager( AutoDeleteSentMessages(), FriendNotes(), DoubleTapChatAction(), + VideoRecordTimer(), SnapScoreChanges(), DisableSnapModeRestrictions(), MessageTranslator(), diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/VideoRecordTimer.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/VideoRecordTimer.kt new file mode 100644 index 00000000..7edaa42e --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/tweaks/VideoRecordTimer.kt @@ -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() + + 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().hashCode()) + } + + AudioRecord::class.java.hook("stop", HookStage.BEFORE) { + stopRecording(it.thisObject().hashCode()) + } + + AudioRecord::class.java.hook("release", HookStage.BEFORE) { + stopRecording(it.thisObject().hashCode()) + } + + MediaCodec::class.java.hook("start", HookStage.AFTER) { + val codecName = runCatching { it.thisObject().name }.getOrNull() ?: "" + if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) { + startRecording(it.thisObject().hashCode()) + } + } + + MediaCodec::class.java.hook("stop", HookStage.BEFORE) { + val codecName = runCatching { it.thisObject().name }.getOrNull() ?: "" + if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) { + stopRecording(it.thisObject().hashCode()) + } + } + + MediaCodec::class.java.hook("release", HookStage.BEFORE) { + val codecName = runCatching { it.thisObject().name }.getOrNull() ?: "" + if (codecName.contains("encoder", ignoreCase = true) && codecName.contains("video", ignoreCase = true)) { + stopRecording(it.thisObject().hashCode()) + } + } + } +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FakeSnapScore.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FakeSnapScore.kt index 9dd5588d..589edc70 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FakeSnapScore.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/FakeSnapScore.kt @@ -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 = mutableListOf()): List { + 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(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() } } } diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt index b515d0f9..6c04a991 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryCounter.kt @@ -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) { diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt index 85517573..caae5c44 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlay.kt @@ -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("story_counter") != null || - event.parent.findViewWithTag("story_counter") != null) return@subscribe + event.parent.findViewWithTag("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) + ) +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt index fcb28c68..6f666c3c 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStoryOverlayState.kt @@ -20,6 +20,8 @@ class OperaStoryOverlayState { val sourceState = mutableStateOf("") val currentIndexState = mutableIntStateOf(-1) val totalCountState = mutableIntStateOf(0) + val snapSourceState = mutableStateOf(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) diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJumpDialog.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJumpDialog.kt new file mode 100644 index 00000000..4ca95d6d --- /dev/null +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/features/impl/ui/OperaStorySnapJumpDialog.kt @@ -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 + ) + } + } + } + } + } +} diff --git a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/InAppOverlay.kt b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/InAppOverlay.kt index c731276c..b5d96b1f 100644 --- a/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/InAppOverlay.kt +++ b/core/src/main/kotlin/me/eternal/purrfectsnap/core/ui/InAppOverlay.kt @@ -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 + ) + } + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 60376430..18364c15 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -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" }