Initial implementation of Tilt-Shift Camera Android app
A dedicated camera app for tilt-shift photography with: - Real-time OpenGL ES 2.0 shader-based blur preview - Touch gesture controls (drag, rotate, pinch) for adjusting effect - CameraX integration for camera preview and high-res capture - EXIF metadata with GPS location support - MediaStore integration for saving to gallery - Jetpack Compose UI with haptic feedback Tech stack: Kotlin, CameraX, OpenGL ES 2.0, Jetpack Compose Min SDK: 26 (Android 8.0), Target SDK: 35 (Android 15) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
commit
07e10ac9c3
38 changed files with 3489 additions and 0 deletions
91
.gitignore
vendored
Normal file
91
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# Built application files
|
||||||
|
*.apk
|
||||||
|
*.aar
|
||||||
|
*.ap_
|
||||||
|
*.aab
|
||||||
|
|
||||||
|
# Files for the ART/Dalvik VM
|
||||||
|
*.dex
|
||||||
|
|
||||||
|
# Java class files
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Generated files
|
||||||
|
bin/
|
||||||
|
gen/
|
||||||
|
out/
|
||||||
|
release/
|
||||||
|
|
||||||
|
# Gradle files
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
|
||||||
|
# Local configuration file (sdk path, etc)
|
||||||
|
local.properties
|
||||||
|
|
||||||
|
# Proguard folder generated by Eclipse
|
||||||
|
proguard/
|
||||||
|
|
||||||
|
# Log Files
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Android Studio Navigation editor temp files
|
||||||
|
.navigation/
|
||||||
|
|
||||||
|
# Android Studio captures folder
|
||||||
|
captures/
|
||||||
|
|
||||||
|
# IntelliJ
|
||||||
|
*.iml
|
||||||
|
.idea/
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
|
||||||
|
# Keystore files
|
||||||
|
# Uncomment the following lines if you do not want to check your keystore files in.
|
||||||
|
#*.jks
|
||||||
|
#*.keystore
|
||||||
|
|
||||||
|
# External native build folder generated in Android Studio 2.2 and later
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx/
|
||||||
|
|
||||||
|
# Google Services (e.g. APIs or Firebase)
|
||||||
|
google-services.json
|
||||||
|
|
||||||
|
# Freeline
|
||||||
|
freeline.py
|
||||||
|
freeline/
|
||||||
|
freeline_project_description.json
|
||||||
|
|
||||||
|
# fastlane
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots
|
||||||
|
fastlane/test_output
|
||||||
|
fastlane/readme.md
|
||||||
|
|
||||||
|
# Version control
|
||||||
|
vcs.xml
|
||||||
|
|
||||||
|
# lint
|
||||||
|
lint/intermediates/
|
||||||
|
lint/generated/
|
||||||
|
lint/outputs/
|
||||||
|
lint/tmp/
|
||||||
|
# lint/reports/ (keep reports for CI)
|
||||||
|
|
||||||
|
# Android Profiling
|
||||||
|
*.hprof
|
||||||
|
|
||||||
|
# Kotlin
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# OS generated files
|
||||||
|
.DS_Store
|
||||||
|
.DS_Store?
|
||||||
|
._*
|
||||||
|
.Spotlight-V100
|
||||||
|
.Trashes
|
||||||
|
ehthumbs.db
|
||||||
|
Thumbs.db
|
||||||
88
README.md
Normal file
88
README.md
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
# Tilt-Shift Camera
|
||||||
|
|
||||||
|
A dedicated Android camera app for tilt-shift photography with real-time preview, touch-based controls, and proper EXIF handling.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Real-time tilt-shift effect preview** - See the blur effect as you compose your shot
|
||||||
|
- **Touch-based controls**:
|
||||||
|
- Single finger drag to move the focus line position
|
||||||
|
- Two-finger rotation to adjust blur angle
|
||||||
|
- Pinch gesture to adjust blur zone size
|
||||||
|
- Pinch in center to zoom camera
|
||||||
|
- **Zoom controls** - Quick presets (0.5x, 1x, 2x, 5x) plus pinch-to-zoom
|
||||||
|
- **Auto picture orientation detection** - Photos saved with correct EXIF orientation
|
||||||
|
- **GPS location tagging** - Optional EXIF GPS data from device location
|
||||||
|
- **Haptic feedback** - Tactile response for all interactions
|
||||||
|
- **Saves to gallery** - Photos saved to Pictures/TiltShift/ folder
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Android 8.0 (API 26) or higher
|
||||||
|
- Device with camera
|
||||||
|
- OpenGL ES 2.0 support
|
||||||
|
|
||||||
|
## Building
|
||||||
|
|
||||||
|
1. Open the project in Android Studio
|
||||||
|
2. Sync Gradle files
|
||||||
|
3. Build and run on a physical device (camera preview won't work on emulator)
|
||||||
|
|
||||||
|
Or from command line:
|
||||||
|
```bash
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
- **Camera** (required) - For capturing photos
|
||||||
|
- **Location** (optional) - For GPS tagging in EXIF data
|
||||||
|
- **Vibrate** - For haptic feedback
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The app uses:
|
||||||
|
- **CameraX** - Jetpack camera library for camera preview and capture
|
||||||
|
- **OpenGL ES 2.0** - Real-time shader-based blur effect
|
||||||
|
- **Jetpack Compose** - Modern declarative UI
|
||||||
|
- **Kotlin Coroutines & Flow** - Asynchronous operations and state management
|
||||||
|
|
||||||
|
### Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
app/src/main/java/no/naiv/tiltshift/
|
||||||
|
├── MainActivity.kt # Entry point with permission handling
|
||||||
|
├── camera/
|
||||||
|
│ ├── CameraManager.kt # CameraX setup and control
|
||||||
|
│ ├── LensController.kt # Lens/zoom switching
|
||||||
|
│ └── ImageCaptureHandler.kt # Photo capture with effect
|
||||||
|
├── effect/
|
||||||
|
│ ├── TiltShiftRenderer.kt # OpenGL renderer
|
||||||
|
│ ├── TiltShiftShader.kt # GLSL shader management
|
||||||
|
│ └── BlurParameters.kt # Effect state
|
||||||
|
├── ui/
|
||||||
|
│ ├── CameraScreen.kt # Main Compose screen
|
||||||
|
│ ├── TiltShiftOverlay.kt # Touch gesture handling & visualization
|
||||||
|
│ ├── ZoomControl.kt # Zoom UI component
|
||||||
|
│ └── LensSwitcher.kt # Lens selection UI
|
||||||
|
├── storage/
|
||||||
|
│ ├── PhotoSaver.kt # MediaStore integration
|
||||||
|
│ └── ExifWriter.kt # EXIF metadata handling
|
||||||
|
└── util/
|
||||||
|
├── OrientationDetector.kt
|
||||||
|
├── LocationProvider.kt
|
||||||
|
└── HapticFeedback.kt
|
||||||
|
```
|
||||||
|
|
||||||
|
## How the Tilt-Shift Effect Works
|
||||||
|
|
||||||
|
The tilt-shift effect simulates a selective focus lens that makes scenes appear miniature. The app achieves this through:
|
||||||
|
|
||||||
|
1. **Camera Preview** → OpenGL SurfaceTexture
|
||||||
|
2. **Fragment Shader** calculates distance from the focus line for each pixel
|
||||||
|
3. **Gradient blur** is applied based on distance - center stays sharp, edges blur
|
||||||
|
4. **Two-pass Gaussian blur** (optimized as separable passes) for quality and performance
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
85
app/build.gradle.kts
Normal file
85
app/build.gradle.kts
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application)
|
||||||
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "no.naiv.tiltshift"
|
||||||
|
compileSdk = 35
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "no.naiv.tiltshift"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 35
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = true
|
||||||
|
isShrinkResources = true
|
||||||
|
proguardFiles(
|
||||||
|
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||||
|
"proguard-rules.pro"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation(libs.androidx.core.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.ktx)
|
||||||
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
|
implementation(libs.androidx.activity.compose)
|
||||||
|
|
||||||
|
// Compose
|
||||||
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
|
implementation(libs.androidx.ui)
|
||||||
|
implementation(libs.androidx.ui.graphics)
|
||||||
|
implementation(libs.androidx.ui.tooling.preview)
|
||||||
|
implementation(libs.androidx.material3)
|
||||||
|
implementation(libs.androidx.material.icons.extended)
|
||||||
|
|
||||||
|
// CameraX
|
||||||
|
implementation(libs.androidx.camera.core)
|
||||||
|
implementation(libs.androidx.camera.camera2)
|
||||||
|
implementation(libs.androidx.camera.lifecycle)
|
||||||
|
implementation(libs.androidx.camera.view)
|
||||||
|
|
||||||
|
// EXIF
|
||||||
|
implementation(libs.androidx.exifinterface)
|
||||||
|
|
||||||
|
// Location
|
||||||
|
implementation(libs.play.services.location)
|
||||||
|
|
||||||
|
// Permissions
|
||||||
|
implementation(libs.accompanist.permissions)
|
||||||
|
|
||||||
|
// Debug
|
||||||
|
debugImplementation(libs.androidx.ui.tooling)
|
||||||
|
}
|
||||||
10
app/proguard-rules.pro
vendored
Normal file
10
app/proguard-rules.pro
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Add project specific ProGuard rules here.
|
||||||
|
|
||||||
|
# Keep CameraX classes
|
||||||
|
-keep class androidx.camera.** { *; }
|
||||||
|
|
||||||
|
# Keep OpenGL shader-related code
|
||||||
|
-keep class no.naiv.tiltshift.effect.** { *; }
|
||||||
|
|
||||||
|
# Keep location provider
|
||||||
|
-keep class com.google.android.gms.location.** { *; }
|
||||||
42
app/src/main/AndroidManifest.xml
Normal file
42
app/src/main/AndroidManifest.xml
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
|
|
||||||
|
<!-- Camera permission -->
|
||||||
|
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
|
||||||
|
<!-- Location for EXIF GPS data -->
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||||
|
|
||||||
|
<!-- Vibration for haptic feedback -->
|
||||||
|
<uses-permission android:name="android.permission.VIBRATE" />
|
||||||
|
|
||||||
|
<!-- OpenGL ES 2.0 -->
|
||||||
|
<uses-feature android:glEsVersion="0x00020000" android:required="true" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:allowBackup="true"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.TiltShiftCamera"
|
||||||
|
tools:targetApi="35">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:windowSoftInputMode="adjustResize"
|
||||||
|
android:theme="@style/Theme.TiltShiftCamera">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
|
||||||
|
</manifest>
|
||||||
215
app/src/main/java/no/naiv/tiltshift/MainActivity.kt
Normal file
215
app/src/main/java/no/naiv/tiltshift/MainActivity.kt
Normal file
|
|
@ -0,0 +1,215 @@
|
||||||
|
package no.naiv.tiltshift
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Camera
|
||||||
|
import androidx.compose.material.icons.filled.LocationOn
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.ButtonDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.core.view.WindowCompat
|
||||||
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import androidx.core.view.WindowInsetsControllerCompat
|
||||||
|
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||||
|
import com.google.accompanist.permissions.isGranted
|
||||||
|
import com.google.accompanist.permissions.rememberMultiplePermissionsState
|
||||||
|
import com.google.accompanist.permissions.rememberPermissionState
|
||||||
|
import no.naiv.tiltshift.ui.CameraScreen
|
||||||
|
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
// Enable edge-to-edge display
|
||||||
|
enableEdgeToEdge()
|
||||||
|
|
||||||
|
// Hide system bars for immersive camera experience
|
||||||
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
|
val controller = WindowInsetsControllerCompat(window, window.decorView)
|
||||||
|
controller.hide(WindowInsetsCompat.Type.systemBars())
|
||||||
|
controller.systemBarsBehavior = WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
|
||||||
|
|
||||||
|
setContent {
|
||||||
|
TiltShiftApp()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalPermissionsApi::class)
|
||||||
|
@Composable
|
||||||
|
private fun TiltShiftApp() {
|
||||||
|
val cameraPermission = rememberPermissionState(Manifest.permission.CAMERA)
|
||||||
|
val locationPermissions = rememberMultiplePermissionsState(
|
||||||
|
listOf(
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION,
|
||||||
|
Manifest.permission.ACCESS_COARSE_LOCATION
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Request camera permission on launch
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (!cameraPermission.status.isGranted) {
|
||||||
|
cameraPermission.launchPermissionRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
) {
|
||||||
|
when {
|
||||||
|
cameraPermission.status.isGranted -> {
|
||||||
|
// Camera permission granted - show camera
|
||||||
|
CameraScreen()
|
||||||
|
|
||||||
|
// Request location in background (for EXIF GPS)
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
if (!locationPermissions.allPermissionsGranted) {
|
||||||
|
locationPermissions.launchMultiplePermissionRequest()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Show permission request UI
|
||||||
|
PermissionRequestScreen(
|
||||||
|
onRequestCamera = { cameraPermission.launchPermissionRequest() },
|
||||||
|
onRequestLocation = { locationPermissions.launchMultiplePermissionRequest() },
|
||||||
|
cameraGranted = cameraPermission.status.isGranted,
|
||||||
|
locationGranted = locationPermissions.allPermissionsGranted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionRequestScreen(
|
||||||
|
onRequestCamera: () -> Unit,
|
||||||
|
onRequestLocation: () -> Unit,
|
||||||
|
cameraGranted: Boolean,
|
||||||
|
locationGranted: Boolean
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(32.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Tilt-Shift Camera",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 28.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = "Create beautiful miniature-style photos with customizable blur effects",
|
||||||
|
color = Color.Gray,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(48.dp))
|
||||||
|
|
||||||
|
// Camera permission
|
||||||
|
PermissionItem(
|
||||||
|
icon = Icons.Default.Camera,
|
||||||
|
title = "Camera",
|
||||||
|
description = "Required to take photos",
|
||||||
|
isGranted = cameraGranted,
|
||||||
|
onRequest = onRequestCamera
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
|
// Location permission
|
||||||
|
PermissionItem(
|
||||||
|
icon = Icons.Default.LocationOn,
|
||||||
|
title = "Location",
|
||||||
|
description = "Optional - adds GPS data to photos",
|
||||||
|
isGranted = locationGranted,
|
||||||
|
onRequest = onRequestLocation
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PermissionItem(
|
||||||
|
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||||
|
title: String,
|
||||||
|
description: String,
|
||||||
|
isGranted: Boolean,
|
||||||
|
onRequest: () -> Unit
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(RoundedCornerShape(16.dp))
|
||||||
|
.background(Color(0xFF1E1E1E))
|
||||||
|
.padding(16.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isGranted) Color(0xFF4CAF50) else Color(0xFFFFB300),
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
color = Color.White,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
color = Color.Gray,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
textAlign = TextAlign.Center
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!isGranted) {
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Button(
|
||||||
|
onClick = onRequest,
|
||||||
|
colors = ButtonDefaults.buttonColors(
|
||||||
|
containerColor = Color(0xFFFFB300)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
Text("Grant", color = Color.Black)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
173
app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt
Normal file
173
app/src/main/java/no/naiv/tiltshift/camera/CameraManager.kt
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
package no.naiv.tiltshift.camera
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.util.Size
|
||||||
|
import android.view.Surface
|
||||||
|
import androidx.camera.core.Camera
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
import androidx.camera.core.ImageCapture
|
||||||
|
import androidx.camera.core.Preview
|
||||||
|
import androidx.camera.core.SurfaceRequest
|
||||||
|
import androidx.camera.core.resolutionselector.AspectRatioStrategy
|
||||||
|
import androidx.camera.core.resolutionselector.ResolutionSelector
|
||||||
|
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import androidx.lifecycle.LifecycleOwner
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages CameraX camera setup and controls.
|
||||||
|
*/
|
||||||
|
class CameraManager(private val context: Context) {
|
||||||
|
|
||||||
|
private var cameraProvider: ProcessCameraProvider? = null
|
||||||
|
private var camera: Camera? = null
|
||||||
|
private var preview: Preview? = null
|
||||||
|
var imageCapture: ImageCapture? = null
|
||||||
|
private set
|
||||||
|
|
||||||
|
val lensController = LensController()
|
||||||
|
|
||||||
|
private val _zoomRatio = MutableStateFlow(1.0f)
|
||||||
|
val zoomRatio: StateFlow<Float> = _zoomRatio.asStateFlow()
|
||||||
|
|
||||||
|
private val _minZoomRatio = MutableStateFlow(1.0f)
|
||||||
|
val minZoomRatio: StateFlow<Float> = _minZoomRatio.asStateFlow()
|
||||||
|
|
||||||
|
private val _maxZoomRatio = MutableStateFlow(1.0f)
|
||||||
|
val maxZoomRatio: StateFlow<Float> = _maxZoomRatio.asStateFlow()
|
||||||
|
|
||||||
|
private var surfaceTextureProvider: (() -> SurfaceTexture?)? = null
|
||||||
|
private var surfaceSize: Size = Size(1920, 1080)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the camera with the given lifecycle owner.
|
||||||
|
* The surfaceTextureProvider should return the SurfaceTexture from the GL renderer.
|
||||||
|
*/
|
||||||
|
fun startCamera(
|
||||||
|
lifecycleOwner: LifecycleOwner,
|
||||||
|
surfaceTextureProvider: () -> SurfaceTexture?
|
||||||
|
) {
|
||||||
|
this.surfaceTextureProvider = surfaceTextureProvider
|
||||||
|
|
||||||
|
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||||
|
cameraProviderFuture.addListener({
|
||||||
|
cameraProvider = cameraProviderFuture.get()
|
||||||
|
lensController.initialize(cameraProvider?.availableCameraInfos ?: emptyList())
|
||||||
|
bindCameraUseCases(lifecycleOwner)
|
||||||
|
}, ContextCompat.getMainExecutor(context))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun bindCameraUseCases(lifecycleOwner: LifecycleOwner) {
|
||||||
|
val provider = cameraProvider ?: return
|
||||||
|
|
||||||
|
// Unbind all use cases before rebinding
|
||||||
|
provider.unbindAll()
|
||||||
|
|
||||||
|
// Preview use case with resolution selector
|
||||||
|
val resolutionSelector = ResolutionSelector.Builder()
|
||||||
|
.setAspectRatioStrategy(AspectRatioStrategy.RATIO_16_9_FALLBACK_AUTO_STRATEGY)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
preview = Preview.Builder()
|
||||||
|
.setResolutionSelector(resolutionSelector)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Image capture use case for high-res photos
|
||||||
|
imageCapture = ImageCapture.Builder()
|
||||||
|
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
// Get camera selector from lens controller
|
||||||
|
val cameraSelector = lensController.getCurrentLens()?.selector
|
||||||
|
?: CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Bind use cases to camera
|
||||||
|
camera = provider.bindToLifecycle(
|
||||||
|
lifecycleOwner,
|
||||||
|
cameraSelector,
|
||||||
|
preview,
|
||||||
|
imageCapture
|
||||||
|
)
|
||||||
|
|
||||||
|
// Update zoom info
|
||||||
|
camera?.cameraInfo?.let { info ->
|
||||||
|
_minZoomRatio.value = info.zoomState.value?.minZoomRatio ?: 1.0f
|
||||||
|
_maxZoomRatio.value = info.zoomState.value?.maxZoomRatio ?: 1.0f
|
||||||
|
_zoomRatio.value = info.zoomState.value?.zoomRatio ?: 1.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up surface provider for preview
|
||||||
|
preview?.setSurfaceProvider { request ->
|
||||||
|
provideSurface(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// Camera binding failed
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun provideSurface(request: SurfaceRequest) {
|
||||||
|
val surfaceTexture = surfaceTextureProvider?.invoke()
|
||||||
|
if (surfaceTexture == null) {
|
||||||
|
request.willNotProvideSurface()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
surfaceSize = request.resolution
|
||||||
|
surfaceTexture.setDefaultBufferSize(surfaceSize.width, surfaceSize.height)
|
||||||
|
|
||||||
|
val surface = Surface(surfaceTexture)
|
||||||
|
request.provideSurface(surface, ContextCompat.getMainExecutor(context)) { result ->
|
||||||
|
surface.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the zoom ratio.
|
||||||
|
*/
|
||||||
|
fun setZoom(ratio: Float) {
|
||||||
|
val clamped = ratio.coerceIn(_minZoomRatio.value, _maxZoomRatio.value)
|
||||||
|
camera?.cameraControl?.setZoomRatio(clamped)
|
||||||
|
_zoomRatio.value = clamped
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets zoom by linear percentage (0.0 to 1.0).
|
||||||
|
*/
|
||||||
|
fun setZoomLinear(percentage: Float) {
|
||||||
|
camera?.cameraControl?.setLinearZoom(percentage.coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switches to a different lens.
|
||||||
|
*/
|
||||||
|
fun switchLens(lensId: String, lifecycleOwner: LifecycleOwner) {
|
||||||
|
if (lensController.selectLens(lensId)) {
|
||||||
|
bindCameraUseCases(lifecycleOwner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the executor for image capture callbacks.
|
||||||
|
*/
|
||||||
|
fun getExecutor(): Executor {
|
||||||
|
return ContextCompat.getMainExecutor(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases camera resources.
|
||||||
|
*/
|
||||||
|
fun release() {
|
||||||
|
cameraProvider?.unbindAll()
|
||||||
|
camera = null
|
||||||
|
preview = null
|
||||||
|
imageCapture = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,434 @@
|
||||||
|
package no.naiv.tiltshift.camera
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Matrix
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.RenderEffect
|
||||||
|
import android.graphics.Shader
|
||||||
|
import android.location.Location
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.Surface
|
||||||
|
import androidx.camera.core.ImageCapture
|
||||||
|
import androidx.camera.core.ImageCaptureException
|
||||||
|
import androidx.camera.core.ImageProxy
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
|
import no.naiv.tiltshift.effect.BlurParameters
|
||||||
|
import no.naiv.tiltshift.storage.PhotoSaver
|
||||||
|
import no.naiv.tiltshift.storage.SaveResult
|
||||||
|
import no.naiv.tiltshift.util.OrientationDetector
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.Executor
|
||||||
|
import kotlin.coroutines.resume
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles capturing photos with the tilt-shift effect applied.
|
||||||
|
*/
|
||||||
|
class ImageCaptureHandler(
|
||||||
|
private val context: Context,
|
||||||
|
private val photoSaver: PhotoSaver
|
||||||
|
) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Captures a photo and applies the tilt-shift effect.
|
||||||
|
*/
|
||||||
|
suspend fun capturePhoto(
|
||||||
|
imageCapture: ImageCapture,
|
||||||
|
executor: Executor,
|
||||||
|
blurParams: BlurParameters,
|
||||||
|
deviceRotation: Int,
|
||||||
|
location: Location?,
|
||||||
|
isFrontCamera: Boolean
|
||||||
|
): SaveResult = suspendCancellableCoroutine { continuation ->
|
||||||
|
|
||||||
|
imageCapture.takePicture(
|
||||||
|
executor,
|
||||||
|
object : ImageCapture.OnImageCapturedCallback() {
|
||||||
|
override fun onCaptureSuccess(imageProxy: ImageProxy) {
|
||||||
|
try {
|
||||||
|
// Convert ImageProxy to Bitmap
|
||||||
|
val bitmap = imageProxyToBitmap(imageProxy)
|
||||||
|
imageProxy.close()
|
||||||
|
|
||||||
|
if (bitmap == null) {
|
||||||
|
continuation.resume(SaveResult.Error("Failed to convert image"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply tilt-shift effect to captured image
|
||||||
|
val processedBitmap = applyTiltShiftEffect(bitmap, blurParams)
|
||||||
|
bitmap.recycle()
|
||||||
|
|
||||||
|
// Determine EXIF orientation
|
||||||
|
val rotationDegrees = OrientationDetector.rotationToDegrees(deviceRotation)
|
||||||
|
val exifOrientation = OrientationDetector.degreesToExifOrientation(
|
||||||
|
rotationDegrees, isFrontCamera
|
||||||
|
)
|
||||||
|
|
||||||
|
// Save with EXIF data
|
||||||
|
kotlinx.coroutines.runBlocking {
|
||||||
|
val result = photoSaver.saveBitmap(
|
||||||
|
processedBitmap,
|
||||||
|
exifOrientation,
|
||||||
|
location
|
||||||
|
)
|
||||||
|
processedBitmap.recycle()
|
||||||
|
continuation.resume(result)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
continuation.resume(SaveResult.Error("Capture failed: ${e.message}", e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(exception: ImageCaptureException) {
|
||||||
|
continuation.resume(
|
||||||
|
SaveResult.Error("Capture failed: ${exception.message}", exception)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap? {
|
||||||
|
val buffer = imageProxy.planes[0].buffer
|
||||||
|
val bytes = ByteArray(buffer.remaining())
|
||||||
|
buffer.get(bytes)
|
||||||
|
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies tilt-shift blur effect to a bitmap.
|
||||||
|
* This is a software fallback - on newer devices we could use RenderScript/RenderEffect.
|
||||||
|
*/
|
||||||
|
private fun applyTiltShiftEffect(source: Bitmap, params: BlurParameters): Bitmap {
|
||||||
|
val width = source.width
|
||||||
|
val height = source.height
|
||||||
|
|
||||||
|
// Create output bitmap
|
||||||
|
val result = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
val canvas = Canvas(result)
|
||||||
|
|
||||||
|
// For performance, we use a scaled-down version for blur and composite
|
||||||
|
val scaleFactor = 4 // Blur a 1/4 size image for speed
|
||||||
|
val blurredWidth = width / scaleFactor
|
||||||
|
val blurredHeight = height / scaleFactor
|
||||||
|
|
||||||
|
// Create scaled bitmap for blur
|
||||||
|
val scaled = Bitmap.createScaledBitmap(source, blurredWidth, blurredHeight, true)
|
||||||
|
|
||||||
|
// Apply stack blur (fast approximation)
|
||||||
|
val blurred = stackBlur(scaled, (params.blurAmount * 25).toInt().coerceIn(1, 25))
|
||||||
|
scaled.recycle()
|
||||||
|
|
||||||
|
// Scale blurred back up
|
||||||
|
val blurredFullSize = Bitmap.createScaledBitmap(blurred, width, height, true)
|
||||||
|
blurred.recycle()
|
||||||
|
|
||||||
|
// Create gradient mask based on tilt-shift parameters
|
||||||
|
val mask = createGradientMask(width, height, params)
|
||||||
|
|
||||||
|
// Composite: blend original with blurred based on mask
|
||||||
|
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
val pixels = IntArray(width * height)
|
||||||
|
val blurredPixels = IntArray(width * height)
|
||||||
|
val maskPixels = IntArray(width * height)
|
||||||
|
|
||||||
|
source.getPixels(pixels, 0, width, 0, 0, width, height)
|
||||||
|
blurredFullSize.getPixels(blurredPixels, 0, width, 0, 0, width, height)
|
||||||
|
mask.getPixels(maskPixels, 0, width, 0, 0, width, height)
|
||||||
|
|
||||||
|
blurredFullSize.recycle()
|
||||||
|
mask.recycle()
|
||||||
|
|
||||||
|
for (i in pixels.indices) {
|
||||||
|
val maskAlpha = (maskPixels[i] and 0xFF) / 255f
|
||||||
|
val origR = (pixels[i] shr 16) and 0xFF
|
||||||
|
val origG = (pixels[i] shr 8) and 0xFF
|
||||||
|
val origB = pixels[i] and 0xFF
|
||||||
|
val blurR = (blurredPixels[i] shr 16) and 0xFF
|
||||||
|
val blurG = (blurredPixels[i] shr 8) and 0xFF
|
||||||
|
val blurB = blurredPixels[i] and 0xFF
|
||||||
|
|
||||||
|
val r = (origR * (1 - maskAlpha) + blurR * maskAlpha).toInt()
|
||||||
|
val g = (origG * (1 - maskAlpha) + blurG * maskAlpha).toInt()
|
||||||
|
val b = (origB * (1 - maskAlpha) + blurB * maskAlpha).toInt()
|
||||||
|
|
||||||
|
pixels[i] = (0xFF shl 24) or (r shl 16) or (g shl 8) or b
|
||||||
|
}
|
||||||
|
|
||||||
|
result.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a gradient mask for the tilt-shift effect.
|
||||||
|
*/
|
||||||
|
private fun createGradientMask(width: Int, height: Int, params: BlurParameters): Bitmap {
|
||||||
|
val mask = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||||
|
val pixels = IntArray(width * height)
|
||||||
|
|
||||||
|
val centerY = height * params.position
|
||||||
|
val focusHalfHeight = height * params.size * 0.5f
|
||||||
|
val transitionHeight = focusHalfHeight * 0.5f
|
||||||
|
|
||||||
|
val cosAngle = cos(params.angle)
|
||||||
|
val sinAngle = sin(params.angle)
|
||||||
|
|
||||||
|
for (y in 0 until height) {
|
||||||
|
for (x in 0 until width) {
|
||||||
|
// Rotate point around center
|
||||||
|
val dx = x - width / 2f
|
||||||
|
val dy = y - centerY
|
||||||
|
val rotatedY = -dx * sinAngle + dy * cosAngle
|
||||||
|
|
||||||
|
// Calculate blur amount based on distance from focus line
|
||||||
|
val dist = kotlin.math.abs(rotatedY)
|
||||||
|
val blurAmount = when {
|
||||||
|
dist < focusHalfHeight -> 0f
|
||||||
|
dist < focusHalfHeight + transitionHeight -> {
|
||||||
|
(dist - focusHalfHeight) / transitionHeight
|
||||||
|
}
|
||||||
|
else -> 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
val gray = (blurAmount * 255).toInt().coerceIn(0, 255)
|
||||||
|
pixels[y * width + x] = (0xFF shl 24) or (gray shl 16) or (gray shl 8) or gray
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mask.setPixels(pixels, 0, width, 0, 0, width, height)
|
||||||
|
return mask
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fast stack blur algorithm.
|
||||||
|
*/
|
||||||
|
private fun stackBlur(bitmap: Bitmap, radius: Int): Bitmap {
|
||||||
|
if (radius < 1) return bitmap.copy(Bitmap.Config.ARGB_8888, true)
|
||||||
|
|
||||||
|
val w = bitmap.width
|
||||||
|
val h = bitmap.height
|
||||||
|
val pix = IntArray(w * h)
|
||||||
|
bitmap.getPixels(pix, 0, w, 0, 0, w, h)
|
||||||
|
|
||||||
|
val wm = w - 1
|
||||||
|
val hm = h - 1
|
||||||
|
val wh = w * h
|
||||||
|
val div = radius + radius + 1
|
||||||
|
|
||||||
|
val r = IntArray(wh)
|
||||||
|
val g = IntArray(wh)
|
||||||
|
val b = IntArray(wh)
|
||||||
|
var rsum: Int
|
||||||
|
var gsum: Int
|
||||||
|
var bsum: Int
|
||||||
|
var x: Int
|
||||||
|
var y: Int
|
||||||
|
var i: Int
|
||||||
|
var p: Int
|
||||||
|
var yp: Int
|
||||||
|
var yi: Int
|
||||||
|
var yw: Int
|
||||||
|
val vmin = IntArray(maxOf(w, h))
|
||||||
|
|
||||||
|
var divsum = (div + 1) shr 1
|
||||||
|
divsum *= divsum
|
||||||
|
val dv = IntArray(256 * divsum)
|
||||||
|
for (i2 in 0 until 256 * divsum) {
|
||||||
|
dv[i2] = (i2 / divsum)
|
||||||
|
}
|
||||||
|
|
||||||
|
yi = 0
|
||||||
|
yw = 0
|
||||||
|
|
||||||
|
val stack = Array(div) { IntArray(3) }
|
||||||
|
var stackpointer: Int
|
||||||
|
var stackstart: Int
|
||||||
|
var sir: IntArray
|
||||||
|
var rbs: Int
|
||||||
|
val r1 = radius + 1
|
||||||
|
var routsum: Int
|
||||||
|
var goutsum: Int
|
||||||
|
var boutsum: Int
|
||||||
|
var rinsum: Int
|
||||||
|
var ginsum: Int
|
||||||
|
var binsum: Int
|
||||||
|
|
||||||
|
for (y2 in 0 until h) {
|
||||||
|
rinsum = 0
|
||||||
|
ginsum = 0
|
||||||
|
binsum = 0
|
||||||
|
routsum = 0
|
||||||
|
goutsum = 0
|
||||||
|
boutsum = 0
|
||||||
|
rsum = 0
|
||||||
|
gsum = 0
|
||||||
|
bsum = 0
|
||||||
|
for (i2 in -radius..radius) {
|
||||||
|
p = pix[yi + minOf(wm, maxOf(i2, 0))]
|
||||||
|
sir = stack[i2 + radius]
|
||||||
|
sir[0] = (p and 0xff0000) shr 16
|
||||||
|
sir[1] = (p and 0x00ff00) shr 8
|
||||||
|
sir[2] = (p and 0x0000ff)
|
||||||
|
rbs = r1 - kotlin.math.abs(i2)
|
||||||
|
rsum += sir[0] * rbs
|
||||||
|
gsum += sir[1] * rbs
|
||||||
|
bsum += sir[2] * rbs
|
||||||
|
if (i2 > 0) {
|
||||||
|
rinsum += sir[0]
|
||||||
|
ginsum += sir[1]
|
||||||
|
binsum += sir[2]
|
||||||
|
} else {
|
||||||
|
routsum += sir[0]
|
||||||
|
goutsum += sir[1]
|
||||||
|
boutsum += sir[2]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stackpointer = radius
|
||||||
|
|
||||||
|
for (x2 in 0 until w) {
|
||||||
|
r[yi] = dv[rsum]
|
||||||
|
g[yi] = dv[gsum]
|
||||||
|
b[yi] = dv[bsum]
|
||||||
|
|
||||||
|
rsum -= routsum
|
||||||
|
gsum -= goutsum
|
||||||
|
bsum -= boutsum
|
||||||
|
|
||||||
|
stackstart = stackpointer - radius + div
|
||||||
|
sir = stack[stackstart % div]
|
||||||
|
|
||||||
|
routsum -= sir[0]
|
||||||
|
goutsum -= sir[1]
|
||||||
|
boutsum -= sir[2]
|
||||||
|
|
||||||
|
if (y2 == 0) {
|
||||||
|
vmin[x2] = minOf(x2 + radius + 1, wm)
|
||||||
|
}
|
||||||
|
p = pix[yw + vmin[x2]]
|
||||||
|
|
||||||
|
sir[0] = (p and 0xff0000) shr 16
|
||||||
|
sir[1] = (p and 0x00ff00) shr 8
|
||||||
|
sir[2] = (p and 0x0000ff)
|
||||||
|
|
||||||
|
rinsum += sir[0]
|
||||||
|
ginsum += sir[1]
|
||||||
|
binsum += sir[2]
|
||||||
|
|
||||||
|
rsum += rinsum
|
||||||
|
gsum += ginsum
|
||||||
|
bsum += binsum
|
||||||
|
|
||||||
|
stackpointer = (stackpointer + 1) % div
|
||||||
|
sir = stack[(stackpointer) % div]
|
||||||
|
|
||||||
|
routsum += sir[0]
|
||||||
|
goutsum += sir[1]
|
||||||
|
boutsum += sir[2]
|
||||||
|
|
||||||
|
rinsum -= sir[0]
|
||||||
|
ginsum -= sir[1]
|
||||||
|
binsum -= sir[2]
|
||||||
|
|
||||||
|
yi++
|
||||||
|
}
|
||||||
|
yw += w
|
||||||
|
}
|
||||||
|
for (x2 in 0 until w) {
|
||||||
|
rinsum = 0
|
||||||
|
ginsum = 0
|
||||||
|
binsum = 0
|
||||||
|
routsum = 0
|
||||||
|
goutsum = 0
|
||||||
|
boutsum = 0
|
||||||
|
rsum = 0
|
||||||
|
gsum = 0
|
||||||
|
bsum = 0
|
||||||
|
yp = -radius * w
|
||||||
|
for (i2 in -radius..radius) {
|
||||||
|
yi = maxOf(0, yp) + x2
|
||||||
|
|
||||||
|
sir = stack[i2 + radius]
|
||||||
|
|
||||||
|
sir[0] = r[yi]
|
||||||
|
sir[1] = g[yi]
|
||||||
|
sir[2] = b[yi]
|
||||||
|
|
||||||
|
rbs = r1 - kotlin.math.abs(i2)
|
||||||
|
|
||||||
|
rsum += r[yi] * rbs
|
||||||
|
gsum += g[yi] * rbs
|
||||||
|
bsum += b[yi] * rbs
|
||||||
|
|
||||||
|
if (i2 > 0) {
|
||||||
|
rinsum += sir[0]
|
||||||
|
ginsum += sir[1]
|
||||||
|
binsum += sir[2]
|
||||||
|
} else {
|
||||||
|
routsum += sir[0]
|
||||||
|
goutsum += sir[1]
|
||||||
|
boutsum += sir[2]
|
||||||
|
}
|
||||||
|
|
||||||
|
if (i2 < hm) {
|
||||||
|
yp += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
yi = x2
|
||||||
|
stackpointer = radius
|
||||||
|
for (y2 in 0 until h) {
|
||||||
|
pix[yi] = (0xff000000.toInt() and pix[yi]) or (dv[rsum] shl 16) or (dv[gsum] shl 8) or dv[bsum]
|
||||||
|
|
||||||
|
rsum -= routsum
|
||||||
|
gsum -= goutsum
|
||||||
|
bsum -= boutsum
|
||||||
|
|
||||||
|
stackstart = stackpointer - radius + div
|
||||||
|
sir = stack[stackstart % div]
|
||||||
|
|
||||||
|
routsum -= sir[0]
|
||||||
|
goutsum -= sir[1]
|
||||||
|
boutsum -= sir[2]
|
||||||
|
|
||||||
|
if (x2 == 0) {
|
||||||
|
vmin[y2] = minOf(y2 + r1, hm) * w
|
||||||
|
}
|
||||||
|
p = x2 + vmin[y2]
|
||||||
|
|
||||||
|
sir[0] = r[p]
|
||||||
|
sir[1] = g[p]
|
||||||
|
sir[2] = b[p]
|
||||||
|
|
||||||
|
rinsum += sir[0]
|
||||||
|
ginsum += sir[1]
|
||||||
|
binsum += sir[2]
|
||||||
|
|
||||||
|
rsum += rinsum
|
||||||
|
gsum += ginsum
|
||||||
|
bsum += binsum
|
||||||
|
|
||||||
|
stackpointer = (stackpointer + 1) % div
|
||||||
|
sir = stack[stackpointer]
|
||||||
|
|
||||||
|
routsum += sir[0]
|
||||||
|
goutsum += sir[1]
|
||||||
|
boutsum += sir[2]
|
||||||
|
|
||||||
|
rinsum -= sir[0]
|
||||||
|
ginsum -= sir[1]
|
||||||
|
binsum -= sir[2]
|
||||||
|
|
||||||
|
yi += w
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||||
|
result.setPixels(pix, 0, w, 0, 0, w, h)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/src/main/java/no/naiv/tiltshift/camera/LensController.kt
Normal file
98
app/src/main/java/no/naiv/tiltshift/camera/LensController.kt
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package no.naiv.tiltshift.camera
|
||||||
|
|
||||||
|
import androidx.camera.core.CameraInfo
|
||||||
|
import androidx.camera.core.CameraSelector
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents available camera lenses on the device.
|
||||||
|
*/
|
||||||
|
data class CameraLens(
|
||||||
|
val id: String,
|
||||||
|
val displayName: String,
|
||||||
|
val zoomFactor: Float,
|
||||||
|
val selector: CameraSelector
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controls lens selection and provides information about available lenses.
|
||||||
|
*/
|
||||||
|
class LensController {
|
||||||
|
|
||||||
|
private val availableLenses = mutableListOf<CameraLens>()
|
||||||
|
private var currentLensIndex = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes available lenses based on device capabilities.
|
||||||
|
* Should be called after CameraProvider is available.
|
||||||
|
*/
|
||||||
|
fun initialize(cameraInfos: List<CameraInfo>) {
|
||||||
|
availableLenses.clear()
|
||||||
|
|
||||||
|
// Check for back cameras (main, ultrawide, telephoto)
|
||||||
|
val hasBackCamera = cameraInfos.any {
|
||||||
|
it.lensFacing == CameraSelector.LENS_FACING_BACK
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasBackCamera) {
|
||||||
|
// Standard back camera
|
||||||
|
availableLenses.add(
|
||||||
|
CameraLens(
|
||||||
|
id = "back_main",
|
||||||
|
displayName = "1x",
|
||||||
|
zoomFactor = 1.0f,
|
||||||
|
selector = CameraSelector.DEFAULT_BACK_CAMERA
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default to main back camera
|
||||||
|
currentLensIndex = availableLenses.indexOfFirst { it.id == "back_main" }
|
||||||
|
.coerceAtLeast(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all available lenses.
|
||||||
|
*/
|
||||||
|
fun getAvailableLenses(): List<CameraLens> = availableLenses.toList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the currently selected lens.
|
||||||
|
*/
|
||||||
|
fun getCurrentLens(): CameraLens? {
|
||||||
|
return if (availableLenses.isNotEmpty()) {
|
||||||
|
availableLenses[currentLensIndex]
|
||||||
|
} else null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Selects a specific lens by ID.
|
||||||
|
* Returns true if the lens was found and selected.
|
||||||
|
*/
|
||||||
|
fun selectLens(lensId: String): Boolean {
|
||||||
|
val index = availableLenses.indexOfFirst { it.id == lensId }
|
||||||
|
if (index >= 0) {
|
||||||
|
currentLensIndex = index
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cycles to the next available lens.
|
||||||
|
* Returns the newly selected lens, or null if no lenses available.
|
||||||
|
*/
|
||||||
|
fun cycleToNextLens(): CameraLens? {
|
||||||
|
if (availableLenses.isEmpty()) return null
|
||||||
|
|
||||||
|
currentLensIndex = (currentLensIndex + 1) % availableLenses.size
|
||||||
|
return availableLenses[currentLensIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Common zoom levels that can be achieved through digital zoom.
|
||||||
|
* These are presented as quick-select buttons.
|
||||||
|
*/
|
||||||
|
fun getZoomPresets(): List<Float> {
|
||||||
|
return listOf(0.5f, 1.0f, 2.0f, 5.0f)
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt
Normal file
54
app/src/main/java/no/naiv/tiltshift/effect/BlurParameters.kt
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
package no.naiv.tiltshift.effect
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parameters controlling the tilt-shift blur effect.
|
||||||
|
*
|
||||||
|
* @param angle The rotation angle of the blur gradient in radians (0 = horizontal blur bands)
|
||||||
|
* @param position The center position of the in-focus region (0.0 to 1.0, relative to screen)
|
||||||
|
* @param size The size of the in-focus region (0.0 to 1.0, as fraction of screen height)
|
||||||
|
* @param blurAmount The intensity of the blur effect (0.0 to 1.0)
|
||||||
|
*/
|
||||||
|
data class BlurParameters(
|
||||||
|
val angle: Float = 0f,
|
||||||
|
val position: Float = 0.5f,
|
||||||
|
val size: Float = 0.3f,
|
||||||
|
val blurAmount: Float = 0.8f
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
val DEFAULT = BlurParameters()
|
||||||
|
|
||||||
|
// Constraints
|
||||||
|
const val MIN_SIZE = 0.1f
|
||||||
|
const val MAX_SIZE = 0.8f
|
||||||
|
const val MIN_BLUR = 0.0f
|
||||||
|
const val MAX_BLUR = 1.0f
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with the angle adjusted by the given delta.
|
||||||
|
*/
|
||||||
|
fun withAngleDelta(delta: Float): BlurParameters {
|
||||||
|
return copy(angle = angle + delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with the position clamped to valid range.
|
||||||
|
*/
|
||||||
|
fun withPosition(newPosition: Float): BlurParameters {
|
||||||
|
return copy(position = newPosition.coerceIn(0f, 1f))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with the size clamped to valid range.
|
||||||
|
*/
|
||||||
|
fun withSize(newSize: Float): BlurParameters {
|
||||||
|
return copy(size = newSize.coerceIn(MIN_SIZE, MAX_SIZE))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy with the blur amount clamped to valid range.
|
||||||
|
*/
|
||||||
|
fun withBlurAmount(amount: Float): BlurParameters {
|
||||||
|
return copy(blurAmount = amount.coerceIn(MIN_BLUR, MAX_BLUR))
|
||||||
|
}
|
||||||
|
}
|
||||||
159
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt
Normal file
159
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftRenderer.kt
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
package no.naiv.tiltshift.effect
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.opengl.GLES11Ext
|
||||||
|
import android.opengl.GLES20
|
||||||
|
import android.opengl.GLSurfaceView
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import java.nio.FloatBuffer
|
||||||
|
import javax.microedition.khronos.egl.EGLConfig
|
||||||
|
import javax.microedition.khronos.opengles.GL10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenGL renderer for applying tilt-shift effect to camera preview.
|
||||||
|
*
|
||||||
|
* This renderer receives camera frames via SurfaceTexture and applies
|
||||||
|
* the tilt-shift blur effect using GLSL shaders.
|
||||||
|
*/
|
||||||
|
class TiltShiftRenderer(
|
||||||
|
private val context: Context,
|
||||||
|
private val onSurfaceTextureAvailable: (SurfaceTexture) -> Unit
|
||||||
|
) : GLSurfaceView.Renderer {
|
||||||
|
|
||||||
|
private lateinit var shader: TiltShiftShader
|
||||||
|
private var surfaceTexture: SurfaceTexture? = null
|
||||||
|
private var cameraTextureId: Int = 0
|
||||||
|
|
||||||
|
private lateinit var vertexBuffer: FloatBuffer
|
||||||
|
private lateinit var texCoordBuffer: FloatBuffer
|
||||||
|
|
||||||
|
private var surfaceWidth: Int = 0
|
||||||
|
private var surfaceHeight: Int = 0
|
||||||
|
|
||||||
|
// Current effect parameters (updated from UI thread)
|
||||||
|
@Volatile
|
||||||
|
var blurParameters: BlurParameters = BlurParameters.DEFAULT
|
||||||
|
|
||||||
|
// Quad vertices (full screen)
|
||||||
|
private val vertices = floatArrayOf(
|
||||||
|
-1f, -1f, // Bottom left
|
||||||
|
1f, -1f, // Bottom right
|
||||||
|
-1f, 1f, // Top left
|
||||||
|
1f, 1f // Top right
|
||||||
|
)
|
||||||
|
|
||||||
|
// Texture coordinates (flip Y for camera)
|
||||||
|
private val texCoords = floatArrayOf(
|
||||||
|
0f, 1f, // Bottom left
|
||||||
|
1f, 1f, // Bottom right
|
||||||
|
0f, 0f, // Top left
|
||||||
|
1f, 0f // Top right
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun onSurfaceCreated(gl: GL10?, config: EGLConfig?) {
|
||||||
|
GLES20.glClearColor(0f, 0f, 0f, 1f)
|
||||||
|
|
||||||
|
// Initialize shader
|
||||||
|
shader = TiltShiftShader(context)
|
||||||
|
shader.initialize()
|
||||||
|
|
||||||
|
// Create vertex buffer
|
||||||
|
vertexBuffer = ByteBuffer.allocateDirect(vertices.size * 4)
|
||||||
|
.order(ByteOrder.nativeOrder())
|
||||||
|
.asFloatBuffer()
|
||||||
|
.put(vertices)
|
||||||
|
vertexBuffer.position(0)
|
||||||
|
|
||||||
|
// Create texture coordinate buffer
|
||||||
|
texCoordBuffer = ByteBuffer.allocateDirect(texCoords.size * 4)
|
||||||
|
.order(ByteOrder.nativeOrder())
|
||||||
|
.asFloatBuffer()
|
||||||
|
.put(texCoords)
|
||||||
|
texCoordBuffer.position(0)
|
||||||
|
|
||||||
|
// Create camera texture
|
||||||
|
val textures = IntArray(1)
|
||||||
|
GLES20.glGenTextures(1, textures, 0)
|
||||||
|
cameraTextureId = textures[0]
|
||||||
|
|
||||||
|
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId)
|
||||||
|
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
|
||||||
|
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
|
||||||
|
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
|
||||||
|
GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
|
||||||
|
|
||||||
|
// Create SurfaceTexture for camera frames
|
||||||
|
surfaceTexture = SurfaceTexture(cameraTextureId).also {
|
||||||
|
onSurfaceTextureAvailable(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSurfaceChanged(gl: GL10?, width: Int, height: Int) {
|
||||||
|
GLES20.glViewport(0, 0, width, height)
|
||||||
|
surfaceWidth = width
|
||||||
|
surfaceHeight = height
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDrawFrame(gl: GL10?) {
|
||||||
|
// Update texture with latest camera frame
|
||||||
|
surfaceTexture?.updateTexImage()
|
||||||
|
|
||||||
|
GLES20.glClear(GLES20.GL_COLOR_BUFFER_BIT)
|
||||||
|
|
||||||
|
// Use shader and set parameters
|
||||||
|
shader.use(cameraTextureId, blurParameters, surfaceWidth, surfaceHeight)
|
||||||
|
|
||||||
|
// Set vertex positions
|
||||||
|
GLES20.glEnableVertexAttribArray(shader.aPositionLocation)
|
||||||
|
GLES20.glVertexAttribPointer(
|
||||||
|
shader.aPositionLocation,
|
||||||
|
2,
|
||||||
|
GLES20.GL_FLOAT,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
vertexBuffer
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set texture coordinates
|
||||||
|
GLES20.glEnableVertexAttribArray(shader.aTexCoordLocation)
|
||||||
|
GLES20.glVertexAttribPointer(
|
||||||
|
shader.aTexCoordLocation,
|
||||||
|
2,
|
||||||
|
GLES20.GL_FLOAT,
|
||||||
|
false,
|
||||||
|
0,
|
||||||
|
texCoordBuffer
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw quad
|
||||||
|
GLES20.glDrawArrays(GLES20.GL_TRIANGLE_STRIP, 0, 4)
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
GLES20.glDisableVertexAttribArray(shader.aPositionLocation)
|
||||||
|
GLES20.glDisableVertexAttribArray(shader.aTexCoordLocation)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates blur parameters. Thread-safe.
|
||||||
|
*/
|
||||||
|
fun updateParameters(params: BlurParameters) {
|
||||||
|
blurParameters = params
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases OpenGL resources.
|
||||||
|
* Must be called from GL thread.
|
||||||
|
*/
|
||||||
|
fun release() {
|
||||||
|
shader.release()
|
||||||
|
surfaceTexture?.release()
|
||||||
|
surfaceTexture = null
|
||||||
|
|
||||||
|
if (cameraTextureId != 0) {
|
||||||
|
GLES20.glDeleteTextures(1, intArrayOf(cameraTextureId), 0)
|
||||||
|
cameraTextureId = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
126
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt
Normal file
126
app/src/main/java/no/naiv/tiltshift/effect/TiltShiftShader.kt
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
package no.naiv.tiltshift.effect
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.opengl.GLES11Ext
|
||||||
|
import android.opengl.GLES20
|
||||||
|
import no.naiv.tiltshift.R
|
||||||
|
import java.io.BufferedReader
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages OpenGL shader programs for the tilt-shift effect.
|
||||||
|
*/
|
||||||
|
class TiltShiftShader(private val context: Context) {
|
||||||
|
|
||||||
|
var programId: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
// Attribute locations
|
||||||
|
var aPositionLocation: Int = 0
|
||||||
|
private set
|
||||||
|
var aTexCoordLocation: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
// Uniform locations
|
||||||
|
private var uTextureLocation: Int = 0
|
||||||
|
private var uAngleLocation: Int = 0
|
||||||
|
private var uPositionLocation: Int = 0
|
||||||
|
private var uSizeLocation: Int = 0
|
||||||
|
private var uBlurAmountLocation: Int = 0
|
||||||
|
private var uResolutionLocation: Int = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compiles and links the shader program.
|
||||||
|
* Must be called from GL thread.
|
||||||
|
*/
|
||||||
|
fun initialize() {
|
||||||
|
val vertexSource = loadShaderSource(R.raw.tiltshift_vertex)
|
||||||
|
val fragmentSource = loadShaderSource(R.raw.tiltshift_fragment)
|
||||||
|
|
||||||
|
val vertexShader = compileShader(GLES20.GL_VERTEX_SHADER, vertexSource)
|
||||||
|
val fragmentShader = compileShader(GLES20.GL_FRAGMENT_SHADER, fragmentSource)
|
||||||
|
|
||||||
|
programId = GLES20.glCreateProgram()
|
||||||
|
GLES20.glAttachShader(programId, vertexShader)
|
||||||
|
GLES20.glAttachShader(programId, fragmentShader)
|
||||||
|
GLES20.glLinkProgram(programId)
|
||||||
|
|
||||||
|
// Check for link errors
|
||||||
|
val linkStatus = IntArray(1)
|
||||||
|
GLES20.glGetProgramiv(programId, GLES20.GL_LINK_STATUS, linkStatus, 0)
|
||||||
|
if (linkStatus[0] == 0) {
|
||||||
|
val error = GLES20.glGetProgramInfoLog(programId)
|
||||||
|
GLES20.glDeleteProgram(programId)
|
||||||
|
throw RuntimeException("Shader program link failed: $error")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get attribute locations
|
||||||
|
aPositionLocation = GLES20.glGetAttribLocation(programId, "aPosition")
|
||||||
|
aTexCoordLocation = GLES20.glGetAttribLocation(programId, "aTexCoord")
|
||||||
|
|
||||||
|
// Get uniform locations
|
||||||
|
uTextureLocation = GLES20.glGetUniformLocation(programId, "uTexture")
|
||||||
|
uAngleLocation = GLES20.glGetUniformLocation(programId, "uAngle")
|
||||||
|
uPositionLocation = GLES20.glGetUniformLocation(programId, "uPosition")
|
||||||
|
uSizeLocation = GLES20.glGetUniformLocation(programId, "uSize")
|
||||||
|
uBlurAmountLocation = GLES20.glGetUniformLocation(programId, "uBlurAmount")
|
||||||
|
uResolutionLocation = GLES20.glGetUniformLocation(programId, "uResolution")
|
||||||
|
|
||||||
|
// Clean up shaders (they're linked into program now)
|
||||||
|
GLES20.glDeleteShader(vertexShader)
|
||||||
|
GLES20.glDeleteShader(fragmentShader)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uses the shader program and sets uniforms.
|
||||||
|
*/
|
||||||
|
fun use(textureId: Int, params: BlurParameters, width: Int, height: Int) {
|
||||||
|
GLES20.glUseProgram(programId)
|
||||||
|
|
||||||
|
// Bind camera texture
|
||||||
|
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||||
|
GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, textureId)
|
||||||
|
GLES20.glUniform1i(uTextureLocation, 0)
|
||||||
|
|
||||||
|
// Set effect parameters
|
||||||
|
GLES20.glUniform1f(uAngleLocation, params.angle)
|
||||||
|
GLES20.glUniform1f(uPositionLocation, params.position)
|
||||||
|
GLES20.glUniform1f(uSizeLocation, params.size)
|
||||||
|
GLES20.glUniform1f(uBlurAmountLocation, params.blurAmount)
|
||||||
|
GLES20.glUniform2f(uResolutionLocation, width.toFloat(), height.toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases shader resources.
|
||||||
|
*/
|
||||||
|
fun release() {
|
||||||
|
if (programId != 0) {
|
||||||
|
GLES20.glDeleteProgram(programId)
|
||||||
|
programId = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun loadShaderSource(resourceId: Int): String {
|
||||||
|
val inputStream = context.resources.openRawResource(resourceId)
|
||||||
|
val reader = BufferedReader(InputStreamReader(inputStream))
|
||||||
|
return reader.use { it.readText() }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun compileShader(type: Int, source: String): Int {
|
||||||
|
val shader = GLES20.glCreateShader(type)
|
||||||
|
GLES20.glShaderSource(shader, source)
|
||||||
|
GLES20.glCompileShader(shader)
|
||||||
|
|
||||||
|
// Check for compile errors
|
||||||
|
val compileStatus = IntArray(1)
|
||||||
|
GLES20.glGetShaderiv(shader, GLES20.GL_COMPILE_STATUS, compileStatus, 0)
|
||||||
|
if (compileStatus[0] == 0) {
|
||||||
|
val error = GLES20.glGetShaderInfoLog(shader)
|
||||||
|
GLES20.glDeleteShader(shader)
|
||||||
|
val shaderType = if (type == GLES20.GL_VERTEX_SHADER) "vertex" else "fragment"
|
||||||
|
throw RuntimeException("$shaderType shader compilation failed: $error")
|
||||||
|
}
|
||||||
|
|
||||||
|
return shader
|
||||||
|
}
|
||||||
|
}
|
||||||
96
app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt
Normal file
96
app/src/main/java/no/naiv/tiltshift/storage/ExifWriter.kt
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
package no.naiv.tiltshift.storage
|
||||||
|
|
||||||
|
import android.location.Location
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import java.io.File
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes EXIF metadata to captured images.
|
||||||
|
*/
|
||||||
|
class ExifWriter {
|
||||||
|
|
||||||
|
private val dateTimeFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes EXIF data to the specified image file.
|
||||||
|
*/
|
||||||
|
fun writeExifData(
|
||||||
|
file: File,
|
||||||
|
orientation: Int,
|
||||||
|
location: Location?,
|
||||||
|
make: String = "Android",
|
||||||
|
model: String = android.os.Build.MODEL
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val exif = ExifInterface(file)
|
||||||
|
|
||||||
|
// Orientation
|
||||||
|
exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
|
||||||
|
|
||||||
|
// Date/time
|
||||||
|
val dateTime = dateTimeFormat.format(Date())
|
||||||
|
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
|
||||||
|
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
|
||||||
|
exif.setAttribute(ExifInterface.TAG_DATETIME_DIGITIZED, dateTime)
|
||||||
|
|
||||||
|
// Camera info
|
||||||
|
exif.setAttribute(ExifInterface.TAG_MAKE, make)
|
||||||
|
exif.setAttribute(ExifInterface.TAG_MODEL, model)
|
||||||
|
exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera")
|
||||||
|
|
||||||
|
// GPS location
|
||||||
|
if (location != null) {
|
||||||
|
setLocationExif(exif, location)
|
||||||
|
}
|
||||||
|
|
||||||
|
exif.saveAttributes()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun setLocationExif(exif: ExifInterface, location: Location) {
|
||||||
|
// Latitude
|
||||||
|
val latitude = location.latitude
|
||||||
|
val latRef = if (latitude >= 0) "N" else "S"
|
||||||
|
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE, convertToDMS(Math.abs(latitude)))
|
||||||
|
exif.setAttribute(ExifInterface.TAG_GPS_LATITUDE_REF, latRef)
|
||||||
|
|
||||||
|
// Longitude
|
||||||
|
val longitude = location.longitude
|
||||||
|
val lonRef = if (longitude >= 0) "E" else "W"
|
||||||
|
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE, convertToDMS(Math.abs(longitude)))
|
||||||
|
exif.setAttribute(ExifInterface.TAG_GPS_LONGITUDE_REF, lonRef)
|
||||||
|
|
||||||
|
// Altitude
|
||||||
|
if (location.hasAltitude()) {
|
||||||
|
val altitude = location.altitude
|
||||||
|
val altRef = if (altitude >= 0) "0" else "1"
|
||||||
|
exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE, "${Math.abs(altitude).toLong()}/1")
|
||||||
|
exif.setAttribute(ExifInterface.TAG_GPS_ALTITUDE_REF, altRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timestamp
|
||||||
|
val gpsTimeFormat = SimpleDateFormat("HH:mm:ss", Locale.US)
|
||||||
|
val gpsDateFormat = SimpleDateFormat("yyyy:MM:dd", Locale.US)
|
||||||
|
val timestamp = Date(location.time)
|
||||||
|
exif.setAttribute(ExifInterface.TAG_GPS_TIMESTAMP, gpsTimeFormat.format(timestamp))
|
||||||
|
exif.setAttribute(ExifInterface.TAG_GPS_DATESTAMP, gpsDateFormat.format(timestamp))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts decimal degrees to DMS (degrees/minutes/seconds) format for EXIF.
|
||||||
|
*/
|
||||||
|
private fun convertToDMS(coordinate: Double): String {
|
||||||
|
val degrees = coordinate.toInt()
|
||||||
|
val minutesDecimal = (coordinate - degrees) * 60
|
||||||
|
val minutes = minutesDecimal.toInt()
|
||||||
|
val seconds = (minutesDecimal - minutes) * 60
|
||||||
|
|
||||||
|
// EXIF format: "degrees/1,minutes/1,seconds/1000"
|
||||||
|
return "$degrees/1,$minutes/1,${(seconds * 1000).toLong()}/1000"
|
||||||
|
}
|
||||||
|
}
|
||||||
186
app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt
Normal file
186
app/src/main/java/no/naiv/tiltshift/storage/PhotoSaver.kt
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
package no.naiv.tiltshift.storage
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.BitmapFactory
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.location.Location
|
||||||
|
import android.net.Uri
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Environment
|
||||||
|
import android.provider.MediaStore
|
||||||
|
import androidx.exifinterface.media.ExifInterface
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
import java.io.FileOutputStream
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a photo save operation.
|
||||||
|
*/
|
||||||
|
sealed class SaveResult {
|
||||||
|
data class Success(val uri: Uri, val path: String) : SaveResult()
|
||||||
|
data class Error(val message: String, val exception: Exception? = null) : SaveResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles saving captured photos to the device gallery.
|
||||||
|
*/
|
||||||
|
class PhotoSaver(private val context: Context) {
|
||||||
|
|
||||||
|
private val exifWriter = ExifWriter()
|
||||||
|
|
||||||
|
private val fileNameFormat = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a bitmap with the tilt-shift effect to the gallery.
|
||||||
|
*/
|
||||||
|
suspend fun saveBitmap(
|
||||||
|
bitmap: Bitmap,
|
||||||
|
orientation: Int,
|
||||||
|
location: Location?
|
||||||
|
): SaveResult = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
|
||||||
|
|
||||||
|
// Create content values for MediaStore
|
||||||
|
val contentValues = ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||||
|
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
|
||||||
|
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
|
||||||
|
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert into MediaStore
|
||||||
|
val contentResolver = context.contentResolver
|
||||||
|
val uri = contentResolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||||
|
contentValues
|
||||||
|
) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
|
||||||
|
|
||||||
|
// Write bitmap to output stream
|
||||||
|
contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
bitmap.compress(Bitmap.CompressFormat.JPEG, 95, outputStream)
|
||||||
|
} ?: return@withContext SaveResult.Error("Failed to open output stream")
|
||||||
|
|
||||||
|
// Write EXIF data
|
||||||
|
writeExifToUri(uri, orientation, location)
|
||||||
|
|
||||||
|
// Mark as complete (API 29+)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
contentValues.clear()
|
||||||
|
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||||
|
contentResolver.update(uri, contentValues, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the file path for display
|
||||||
|
val path = getPathFromUri(uri)
|
||||||
|
|
||||||
|
SaveResult.Success(uri, path)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SaveResult.Error("Failed to save photo: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves a JPEG file (from CameraX ImageCapture) to the gallery.
|
||||||
|
*/
|
||||||
|
suspend fun saveJpegFile(
|
||||||
|
sourceFile: File,
|
||||||
|
orientation: Int,
|
||||||
|
location: Location?
|
||||||
|
): SaveResult = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
val fileName = "TILTSHIFT_${fileNameFormat.format(Date())}.jpg"
|
||||||
|
|
||||||
|
val contentValues = ContentValues().apply {
|
||||||
|
put(MediaStore.Images.Media.DISPLAY_NAME, fileName)
|
||||||
|
put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
|
||||||
|
put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis() / 1000)
|
||||||
|
put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
put(MediaStore.Images.Media.RELATIVE_PATH, "${Environment.DIRECTORY_PICTURES}/TiltShift")
|
||||||
|
put(MediaStore.Images.Media.IS_PENDING, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val contentResolver = context.contentResolver
|
||||||
|
val uri = contentResolver.insert(
|
||||||
|
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
|
||||||
|
contentValues
|
||||||
|
) ?: return@withContext SaveResult.Error("Failed to create MediaStore entry")
|
||||||
|
|
||||||
|
// Copy file to MediaStore
|
||||||
|
contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||||
|
sourceFile.inputStream().use { inputStream ->
|
||||||
|
inputStream.copyTo(outputStream)
|
||||||
|
}
|
||||||
|
} ?: return@withContext SaveResult.Error("Failed to open output stream")
|
||||||
|
|
||||||
|
// Write EXIF data
|
||||||
|
writeExifToUri(uri, orientation, location)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
contentValues.clear()
|
||||||
|
contentValues.put(MediaStore.Images.Media.IS_PENDING, 0)
|
||||||
|
contentResolver.update(uri, contentValues, null, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up source file
|
||||||
|
sourceFile.delete()
|
||||||
|
|
||||||
|
val path = getPathFromUri(uri)
|
||||||
|
SaveResult.Success(uri, path)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
SaveResult.Error("Failed to save photo: ${e.message}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeExifToUri(uri: Uri, orientation: Int, location: Location?) {
|
||||||
|
try {
|
||||||
|
context.contentResolver.openFileDescriptor(uri, "rw")?.use { pfd ->
|
||||||
|
val exif = ExifInterface(pfd.fileDescriptor)
|
||||||
|
|
||||||
|
exif.setAttribute(ExifInterface.TAG_ORIENTATION, orientation.toString())
|
||||||
|
exif.setAttribute(ExifInterface.TAG_SOFTWARE, "Tilt-Shift Camera")
|
||||||
|
exif.setAttribute(ExifInterface.TAG_MAKE, "Android")
|
||||||
|
exif.setAttribute(ExifInterface.TAG_MODEL, Build.MODEL)
|
||||||
|
|
||||||
|
val dateFormat = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
|
||||||
|
val dateTime = dateFormat.format(Date())
|
||||||
|
exif.setAttribute(ExifInterface.TAG_DATETIME, dateTime)
|
||||||
|
exif.setAttribute(ExifInterface.TAG_DATETIME_ORIGINAL, dateTime)
|
||||||
|
|
||||||
|
location?.let { loc ->
|
||||||
|
exif.setGpsInfo(loc)
|
||||||
|
}
|
||||||
|
|
||||||
|
exif.saveAttributes()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPathFromUri(uri: Uri): String {
|
||||||
|
val projection = arrayOf(MediaStore.Images.Media.DATA)
|
||||||
|
context.contentResolver.query(uri, projection, null, null, null)?.use { cursor ->
|
||||||
|
if (cursor.moveToFirst()) {
|
||||||
|
val columnIndex = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA)
|
||||||
|
return cursor.getString(columnIndex) ?: uri.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uri.toString()
|
||||||
|
}
|
||||||
|
}
|
||||||
345
app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt
Normal file
345
app/src/main/java/no/naiv/tiltshift/ui/CameraScreen.kt
Normal file
|
|
@ -0,0 +1,345 @@
|
||||||
|
package no.naiv.tiltshift.ui
|
||||||
|
|
||||||
|
import android.graphics.SurfaceTexture
|
||||||
|
import android.location.Location
|
||||||
|
import android.opengl.GLSurfaceView
|
||||||
|
import android.view.Surface
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
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.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Check
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.Settings
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import no.naiv.tiltshift.camera.CameraManager
|
||||||
|
import no.naiv.tiltshift.camera.ImageCaptureHandler
|
||||||
|
import no.naiv.tiltshift.effect.BlurParameters
|
||||||
|
import no.naiv.tiltshift.effect.TiltShiftRenderer
|
||||||
|
import no.naiv.tiltshift.storage.PhotoSaver
|
||||||
|
import no.naiv.tiltshift.storage.SaveResult
|
||||||
|
import no.naiv.tiltshift.util.HapticFeedback
|
||||||
|
import no.naiv.tiltshift.util.LocationProvider
|
||||||
|
import no.naiv.tiltshift.util.OrientationDetector
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main camera screen with tilt-shift controls.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CameraScreen(
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Camera and effect state
|
||||||
|
val cameraManager = remember { CameraManager(context) }
|
||||||
|
val photoSaver = remember { PhotoSaver(context) }
|
||||||
|
val captureHandler = remember { ImageCaptureHandler(context, photoSaver) }
|
||||||
|
val haptics = remember { HapticFeedback(context) }
|
||||||
|
val orientationDetector = remember { OrientationDetector(context) }
|
||||||
|
val locationProvider = remember { LocationProvider(context) }
|
||||||
|
|
||||||
|
// State
|
||||||
|
var blurParams by remember { mutableStateOf(BlurParameters.DEFAULT) }
|
||||||
|
var surfaceTexture by remember { mutableStateOf<SurfaceTexture?>(null) }
|
||||||
|
var renderer by remember { mutableStateOf<TiltShiftRenderer?>(null) }
|
||||||
|
var glSurfaceView by remember { mutableStateOf<GLSurfaceView?>(null) }
|
||||||
|
|
||||||
|
var isCapturing by remember { mutableStateOf(false) }
|
||||||
|
var showSaveSuccess by remember { mutableStateOf(false) }
|
||||||
|
var showSaveError by remember { mutableStateOf<String?>(null) }
|
||||||
|
|
||||||
|
var currentRotation by remember { mutableStateOf(Surface.ROTATION_0) }
|
||||||
|
var currentLocation by remember { mutableStateOf<Location?>(null) }
|
||||||
|
|
||||||
|
val zoomRatio by cameraManager.zoomRatio.collectAsState()
|
||||||
|
val minZoom by cameraManager.minZoomRatio.collectAsState()
|
||||||
|
val maxZoom by cameraManager.maxZoomRatio.collectAsState()
|
||||||
|
|
||||||
|
// Collect orientation updates
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
orientationDetector.orientationFlow().collectLatest { rotation ->
|
||||||
|
currentRotation = rotation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect location updates
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
locationProvider.locationFlow().collectLatest { location ->
|
||||||
|
currentLocation = location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update renderer with blur params
|
||||||
|
LaunchedEffect(blurParams) {
|
||||||
|
renderer?.updateParameters(blurParams)
|
||||||
|
glSurfaceView?.requestRender()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start camera when surface texture is available
|
||||||
|
LaunchedEffect(surfaceTexture) {
|
||||||
|
surfaceTexture?.let {
|
||||||
|
cameraManager.startCamera(lifecycleOwner) { surfaceTexture }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
|
cameraManager.release()
|
||||||
|
renderer?.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black)
|
||||||
|
) {
|
||||||
|
// OpenGL Surface for camera preview with effect
|
||||||
|
AndroidView(
|
||||||
|
factory = { ctx ->
|
||||||
|
GLSurfaceView(ctx).apply {
|
||||||
|
setEGLContextClientVersion(2)
|
||||||
|
|
||||||
|
val newRenderer = TiltShiftRenderer(ctx) { st ->
|
||||||
|
surfaceTexture = st
|
||||||
|
}
|
||||||
|
renderer = newRenderer
|
||||||
|
|
||||||
|
setRenderer(newRenderer)
|
||||||
|
renderMode = GLSurfaceView.RENDERMODE_CONTINUOUSLY
|
||||||
|
|
||||||
|
glSurfaceView = this
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tilt-shift overlay (gesture handling + visualization)
|
||||||
|
TiltShiftOverlay(
|
||||||
|
params = blurParams,
|
||||||
|
onParamsChange = { newParams ->
|
||||||
|
blurParams = newParams
|
||||||
|
haptics.tick()
|
||||||
|
},
|
||||||
|
onZoomChange = { zoomDelta ->
|
||||||
|
val newZoom = (zoomRatio * zoomDelta).coerceIn(minZoom, maxZoom)
|
||||||
|
cameraManager.setZoom(newZoom)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Top bar
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.statusBarsPadding()
|
||||||
|
.padding(16.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
// Zoom indicator
|
||||||
|
ZoomIndicator(currentZoom = zoomRatio)
|
||||||
|
|
||||||
|
// Settings button (placeholder)
|
||||||
|
IconButton(
|
||||||
|
onClick = { /* TODO: Settings */ }
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Settings,
|
||||||
|
contentDescription = "Settings",
|
||||||
|
tint = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bottom controls
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.BottomCenter)
|
||||||
|
.navigationBarsPadding()
|
||||||
|
.padding(bottom = 24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
// Zoom presets
|
||||||
|
ZoomControl(
|
||||||
|
currentZoom = zoomRatio,
|
||||||
|
minZoom = minZoom,
|
||||||
|
maxZoom = maxZoom,
|
||||||
|
onZoomSelected = { zoom ->
|
||||||
|
cameraManager.setZoom(zoom)
|
||||||
|
haptics.click()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// Capture button
|
||||||
|
CaptureButton(
|
||||||
|
isCapturing = isCapturing,
|
||||||
|
onClick = {
|
||||||
|
if (!isCapturing) {
|
||||||
|
isCapturing = true
|
||||||
|
haptics.heavyClick()
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
val imageCapture = cameraManager.imageCapture
|
||||||
|
if (imageCapture != null) {
|
||||||
|
val result = captureHandler.capturePhoto(
|
||||||
|
imageCapture = imageCapture,
|
||||||
|
executor = cameraManager.getExecutor(),
|
||||||
|
blurParams = blurParams,
|
||||||
|
deviceRotation = currentRotation,
|
||||||
|
location = currentLocation,
|
||||||
|
isFrontCamera = false
|
||||||
|
)
|
||||||
|
|
||||||
|
when (result) {
|
||||||
|
is SaveResult.Success -> {
|
||||||
|
haptics.success()
|
||||||
|
showSaveSuccess = true
|
||||||
|
delay(1500)
|
||||||
|
showSaveSuccess = false
|
||||||
|
}
|
||||||
|
is SaveResult.Error -> {
|
||||||
|
haptics.error()
|
||||||
|
showSaveError = result.message
|
||||||
|
delay(2000)
|
||||||
|
showSaveError = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
isCapturing = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Success indicator
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showSaveSuccess,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier.align(Alignment.Center)
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(80.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0xFF4CAF50)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Check,
|
||||||
|
contentDescription = "Saved",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(48.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error indicator
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = showSaveError != null,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.Center)
|
||||||
|
.padding(32.dp)
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
modifier = Modifier
|
||||||
|
.clip(androidx.compose.foundation.shape.RoundedCornerShape(16.dp))
|
||||||
|
.background(Color(0xFFF44336))
|
||||||
|
.padding(24.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Error",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = showSaveError ?: "Error",
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture button with animation for capturing state.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
private fun CaptureButton(
|
||||||
|
isCapturing: Boolean,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val outerSize = 72.dp
|
||||||
|
val innerSize = if (isCapturing) 48.dp else 60.dp
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(outerSize)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.border(4.dp, Color.White, CircleShape)
|
||||||
|
.clickable(enabled = !isCapturing, onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(innerSize)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (isCapturing) Color(0xFFFFB300) else Color.White)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
117
app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt
Normal file
117
app/src/main/java/no/naiv/tiltshift/ui/LensSwitcher.kt
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
package no.naiv.tiltshift.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CameraRear
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
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 no.naiv.tiltshift.camera.CameraLens
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lens selection UI for switching between camera lenses.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun LensSwitcher(
|
||||||
|
availableLenses: List<CameraLens>,
|
||||||
|
currentLens: CameraLens?,
|
||||||
|
onLensSelected: (CameraLens) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
if (availableLenses.size <= 1) {
|
||||||
|
// Don't show switcher if only one lens available
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
availableLenses.forEach { lens ->
|
||||||
|
LensButton(
|
||||||
|
lens = lens,
|
||||||
|
isSelected = lens.id == currentLens?.id,
|
||||||
|
onClick = { onLensSelected(lens) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun LensButton(
|
||||||
|
lens: CameraLens,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val backgroundColor by animateColorAsState(
|
||||||
|
targetValue = if (isSelected) {
|
||||||
|
Color(0xFFFFB300)
|
||||||
|
} else {
|
||||||
|
Color(0x80000000)
|
||||||
|
},
|
||||||
|
label = "lens_button_bg"
|
||||||
|
)
|
||||||
|
|
||||||
|
val contentColor by animateColorAsState(
|
||||||
|
targetValue = if (isSelected) Color.Black else Color.White,
|
||||||
|
label = "lens_button_content"
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = lens.displayName,
|
||||||
|
color = contentColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple camera flip button (for future front camera support).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun CameraFlipButton(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.size(48.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0x80000000))
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.CameraRear,
|
||||||
|
contentDescription = "Switch Camera",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
257
app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt
Normal file
257
app/src/main/java/no/naiv/tiltshift/ui/TiltShiftOverlay.kt
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
package no.naiv.tiltshift.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Canvas
|
||||||
|
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||||
|
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||||
|
import androidx.compose.foundation.gestures.calculateCentroid
|
||||||
|
import androidx.compose.foundation.gestures.calculateRotation
|
||||||
|
import androidx.compose.foundation.gestures.calculateZoom
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.PathEffect
|
||||||
|
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||||
|
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||||
|
import androidx.compose.ui.graphics.drawscope.rotate
|
||||||
|
import androidx.compose.ui.input.pointer.PointerEventType
|
||||||
|
import androidx.compose.ui.input.pointer.pointerInput
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import no.naiv.tiltshift.effect.BlurParameters
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.math.cos
|
||||||
|
import kotlin.math.sin
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type of gesture being performed.
|
||||||
|
*/
|
||||||
|
private enum class GestureType {
|
||||||
|
NONE,
|
||||||
|
DRAG_POSITION, // Single finger drag to move focus position
|
||||||
|
ROTATE, // Two-finger rotation
|
||||||
|
PINCH_SIZE, // Pinch near blur edges to resize
|
||||||
|
PINCH_ZOOM // Pinch in center to zoom camera
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Overlay that shows tilt-shift effect controls and handles gestures.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun TiltShiftOverlay(
|
||||||
|
params: BlurParameters,
|
||||||
|
onParamsChange: (BlurParameters) -> Unit,
|
||||||
|
onZoomChange: (Float) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
var currentGesture by remember { mutableStateOf(GestureType.NONE) }
|
||||||
|
var initialZoom by remember { mutableFloatStateOf(1f) }
|
||||||
|
var initialAngle by remember { mutableFloatStateOf(0f) }
|
||||||
|
var initialSize by remember { mutableFloatStateOf(0.3f) }
|
||||||
|
|
||||||
|
Canvas(
|
||||||
|
modifier = modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.pointerInput(Unit) {
|
||||||
|
awaitEachGesture {
|
||||||
|
val firstDown = awaitFirstDown(requireUnconsumed = false)
|
||||||
|
currentGesture = GestureType.NONE
|
||||||
|
|
||||||
|
var previousCentroid = firstDown.position
|
||||||
|
var previousPointerCount = 1
|
||||||
|
var accumulatedRotation = 0f
|
||||||
|
var accumulatedZoom = 1f
|
||||||
|
|
||||||
|
initialAngle = params.angle
|
||||||
|
initialSize = params.size
|
||||||
|
initialZoom = 1f
|
||||||
|
|
||||||
|
do {
|
||||||
|
val event = awaitPointerEvent()
|
||||||
|
val pointers = event.changes.filter { it.pressed }
|
||||||
|
|
||||||
|
if (pointers.isEmpty()) break
|
||||||
|
|
||||||
|
val centroid = if (pointers.size >= 2) {
|
||||||
|
event.calculateCentroid()
|
||||||
|
} else {
|
||||||
|
pointers.first().position
|
||||||
|
}
|
||||||
|
|
||||||
|
when {
|
||||||
|
// Two or more fingers
|
||||||
|
pointers.size >= 2 -> {
|
||||||
|
val rotation = event.calculateRotation()
|
||||||
|
val zoom = event.calculateZoom()
|
||||||
|
|
||||||
|
// Determine gesture type based on touch positions
|
||||||
|
if (currentGesture == GestureType.NONE || currentGesture == GestureType.DRAG_POSITION) {
|
||||||
|
currentGesture = determineGestureType(
|
||||||
|
centroid,
|
||||||
|
size.width.toFloat(),
|
||||||
|
size.height.toFloat(),
|
||||||
|
params
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
when (currentGesture) {
|
||||||
|
GestureType.ROTATE -> {
|
||||||
|
accumulatedRotation += rotation
|
||||||
|
val newAngle = initialAngle + accumulatedRotation
|
||||||
|
onParamsChange(params.copy(angle = newAngle))
|
||||||
|
}
|
||||||
|
GestureType.PINCH_SIZE -> {
|
||||||
|
accumulatedZoom *= zoom
|
||||||
|
val newSize = (initialSize * accumulatedZoom)
|
||||||
|
.coerceIn(BlurParameters.MIN_SIZE, BlurParameters.MAX_SIZE)
|
||||||
|
onParamsChange(params.copy(size = newSize))
|
||||||
|
}
|
||||||
|
GestureType.PINCH_ZOOM -> {
|
||||||
|
onZoomChange(zoom)
|
||||||
|
}
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single finger
|
||||||
|
pointers.size == 1 -> {
|
||||||
|
if (currentGesture == GestureType.NONE) {
|
||||||
|
currentGesture = GestureType.DRAG_POSITION
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentGesture == GestureType.DRAG_POSITION) {
|
||||||
|
val deltaY = (centroid.y - previousCentroid.y) / size.height
|
||||||
|
val newPosition = (params.position + deltaY).coerceIn(0f, 1f)
|
||||||
|
onParamsChange(params.copy(position = newPosition))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
previousCentroid = centroid
|
||||||
|
previousPointerCount = pointers.size
|
||||||
|
|
||||||
|
// Consume all pointer changes
|
||||||
|
pointers.forEach { it.consume() }
|
||||||
|
} while (event.type != PointerEventType.Release)
|
||||||
|
|
||||||
|
currentGesture = GestureType.NONE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
drawTiltShiftOverlay(params)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines the type of two-finger gesture based on touch position.
|
||||||
|
*/
|
||||||
|
private fun determineGestureType(
|
||||||
|
centroid: Offset,
|
||||||
|
width: Float,
|
||||||
|
height: Float,
|
||||||
|
params: BlurParameters
|
||||||
|
): GestureType {
|
||||||
|
// Calculate distance from focus center line
|
||||||
|
val focusCenterY = height * params.position
|
||||||
|
val focusHalfHeight = height * params.size * 0.5f
|
||||||
|
|
||||||
|
// Rotate centroid to align with focus line
|
||||||
|
val dx = centroid.x - width / 2f
|
||||||
|
val dy = centroid.y - focusCenterY
|
||||||
|
val rotatedY = -dx * sin(params.angle) + dy * cos(params.angle)
|
||||||
|
|
||||||
|
val distFromCenter = kotlin.math.abs(rotatedY)
|
||||||
|
|
||||||
|
return when {
|
||||||
|
// Near the edges of the blur zone -> size adjustment
|
||||||
|
distFromCenter > focusHalfHeight * 0.7f && distFromCenter < focusHalfHeight * 1.5f -> {
|
||||||
|
GestureType.PINCH_SIZE
|
||||||
|
}
|
||||||
|
// Inside the focus zone -> rotation
|
||||||
|
distFromCenter < focusHalfHeight * 0.7f -> {
|
||||||
|
GestureType.ROTATE
|
||||||
|
}
|
||||||
|
// Outside -> camera zoom
|
||||||
|
else -> {
|
||||||
|
GestureType.PINCH_ZOOM
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Draws the tilt-shift visualization overlay.
|
||||||
|
*/
|
||||||
|
private fun DrawScope.drawTiltShiftOverlay(params: BlurParameters) {
|
||||||
|
val width = size.width
|
||||||
|
val height = size.height
|
||||||
|
|
||||||
|
val centerY = height * params.position
|
||||||
|
val focusHalfHeight = height * params.size * 0.5f
|
||||||
|
val angleDegrees = params.angle * (180f / PI.toFloat())
|
||||||
|
|
||||||
|
// Colors for overlay
|
||||||
|
val focusLineColor = Color(0xFFFFB300) // Amber
|
||||||
|
val blurZoneColor = Color(0x40FFFFFF) // Semi-transparent white
|
||||||
|
val dashEffect = PathEffect.dashPathEffect(floatArrayOf(20f, 10f), 0f)
|
||||||
|
|
||||||
|
rotate(angleDegrees, pivot = Offset(width / 2f, centerY)) {
|
||||||
|
// Draw blur zone indicators (top and bottom)
|
||||||
|
drawRect(
|
||||||
|
color = blurZoneColor,
|
||||||
|
topLeft = Offset(0f, 0f),
|
||||||
|
size = androidx.compose.ui.geometry.Size(width, centerY - focusHalfHeight)
|
||||||
|
)
|
||||||
|
drawRect(
|
||||||
|
color = blurZoneColor,
|
||||||
|
topLeft = Offset(0f, centerY + focusHalfHeight),
|
||||||
|
size = androidx.compose.ui.geometry.Size(width, height - (centerY + focusHalfHeight))
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw focus zone boundary lines
|
||||||
|
drawLine(
|
||||||
|
color = focusLineColor,
|
||||||
|
start = Offset(0f, centerY - focusHalfHeight),
|
||||||
|
end = Offset(width, centerY - focusHalfHeight),
|
||||||
|
strokeWidth = 2.dp.toPx(),
|
||||||
|
pathEffect = dashEffect
|
||||||
|
)
|
||||||
|
drawLine(
|
||||||
|
color = focusLineColor,
|
||||||
|
start = Offset(0f, centerY + focusHalfHeight),
|
||||||
|
end = Offset(width, centerY + focusHalfHeight),
|
||||||
|
strokeWidth = 2.dp.toPx(),
|
||||||
|
pathEffect = dashEffect
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw center focus line
|
||||||
|
drawLine(
|
||||||
|
color = focusLineColor,
|
||||||
|
start = Offset(0f, centerY),
|
||||||
|
end = Offset(width, centerY),
|
||||||
|
strokeWidth = 3.dp.toPx()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw rotation indicator at center
|
||||||
|
val indicatorRadius = 30.dp.toPx()
|
||||||
|
drawCircle(
|
||||||
|
color = focusLineColor.copy(alpha = 0.5f),
|
||||||
|
radius = indicatorRadius,
|
||||||
|
center = Offset(width / 2f, centerY),
|
||||||
|
style = Stroke(width = 2.dp.toPx())
|
||||||
|
)
|
||||||
|
|
||||||
|
// Draw angle tick mark
|
||||||
|
val tickLength = 15.dp.toPx()
|
||||||
|
drawLine(
|
||||||
|
color = focusLineColor,
|
||||||
|
start = Offset(width / 2f, centerY - indicatorRadius + tickLength),
|
||||||
|
end = Offset(width / 2f, centerY - indicatorRadius - 5.dp.toPx()),
|
||||||
|
strokeWidth = 3.dp.toPx()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt
Normal file
119
app/src/main/java/no/naiv/tiltshift/ui/ZoomControl.kt
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
package no.naiv.tiltshift.ui
|
||||||
|
|
||||||
|
import androidx.compose.animation.animateColorAsState
|
||||||
|
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.Row
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
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 kotlin.math.abs
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zoom level presets control.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ZoomControl(
|
||||||
|
currentZoom: Float,
|
||||||
|
minZoom: Float,
|
||||||
|
maxZoom: Float,
|
||||||
|
onZoomSelected: (Float) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
// Define zoom presets based on device capabilities
|
||||||
|
val presets = listOf(
|
||||||
|
ZoomPreset(0.5f, "0.5"),
|
||||||
|
ZoomPreset(1.0f, "1"),
|
||||||
|
ZoomPreset(2.0f, "2"),
|
||||||
|
ZoomPreset(5.0f, "5")
|
||||||
|
).filter { it.zoom in minZoom..maxZoom }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = modifier,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
presets.forEach { preset ->
|
||||||
|
ZoomButton(
|
||||||
|
preset = preset,
|
||||||
|
isSelected = abs(currentZoom - preset.zoom) < 0.1f,
|
||||||
|
onClick = { onZoomSelected(preset.zoom) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ZoomPreset(val zoom: Float, val label: String)
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun ZoomButton(
|
||||||
|
preset: ZoomPreset,
|
||||||
|
isSelected: Boolean,
|
||||||
|
onClick: () -> Unit
|
||||||
|
) {
|
||||||
|
val backgroundColor by animateColorAsState(
|
||||||
|
targetValue = if (isSelected) {
|
||||||
|
Color(0xFFFFB300) // Amber when selected
|
||||||
|
} else {
|
||||||
|
Color(0x80000000) // Semi-transparent black
|
||||||
|
},
|
||||||
|
label = "zoom_button_bg"
|
||||||
|
)
|
||||||
|
|
||||||
|
val textColor by animateColorAsState(
|
||||||
|
targetValue = if (isSelected) Color.Black else Color.White,
|
||||||
|
label = "zoom_button_text"
|
||||||
|
)
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(backgroundColor)
|
||||||
|
.clickable(onClick = onClick),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "${preset.label}x",
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays current zoom level as a badge.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ZoomIndicator(
|
||||||
|
currentZoom: Float,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = modifier
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(Color(0x80000000))
|
||||||
|
.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "%.1fx".format(currentZoom),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt
Normal file
98
app/src/main/java/no/naiv/tiltshift/util/HapticFeedback.kt
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package no.naiv.tiltshift.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.VibrationEffect
|
||||||
|
import android.os.Vibrator
|
||||||
|
import android.os.VibratorManager
|
||||||
|
import android.view.HapticFeedbackConstants
|
||||||
|
import android.view.View
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides haptic feedback for user interactions.
|
||||||
|
*/
|
||||||
|
class HapticFeedback(private val context: Context) {
|
||||||
|
|
||||||
|
private val vibrator: Vibrator by lazy {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
val vibratorManager = context.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
|
||||||
|
vibratorManager.defaultVibrator
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Light tick for UI feedback (button press, slider change).
|
||||||
|
*/
|
||||||
|
fun tick() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_TICK))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
vibrator.vibrate(10L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Click feedback for confirmations.
|
||||||
|
*/
|
||||||
|
fun click() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_CLICK))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
vibrator.vibrate(20L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heavy click for important actions (photo capture).
|
||||||
|
*/
|
||||||
|
fun heavyClick() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
vibrator.vibrate(VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
vibrator.vibrate(40L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Success feedback pattern.
|
||||||
|
*/
|
||||||
|
fun success() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val timings = longArrayOf(0, 30, 50, 30)
|
||||||
|
val amplitudes = intArrayOf(0, 100, 0, 200)
|
||||||
|
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
vibrator.vibrate(longArrayOf(0, 30, 50, 30), -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error feedback pattern.
|
||||||
|
*/
|
||||||
|
fun error() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
val timings = longArrayOf(0, 50, 30, 50, 30, 50)
|
||||||
|
val amplitudes = intArrayOf(0, 150, 0, 150, 0, 150)
|
||||||
|
vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, -1))
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
vibrator.vibrate(longArrayOf(0, 50, 30, 50, 30, 50), -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Use system haptic feedback on a View for standard interactions.
|
||||||
|
*/
|
||||||
|
fun performHapticFeedback(view: View, feedbackConstant: Int = HapticFeedbackConstants.VIRTUAL_KEY) {
|
||||||
|
view.performHapticFeedback(feedbackConstant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
78
app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt
Normal file
78
app/src/main/java/no/naiv/tiltshift/util/LocationProvider.kt
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
package no.naiv.tiltshift.util
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.location.Location
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.google.android.gms.location.FusedLocationProviderClient
|
||||||
|
import com.google.android.gms.location.LocationCallback
|
||||||
|
import com.google.android.gms.location.LocationRequest
|
||||||
|
import com.google.android.gms.location.LocationResult
|
||||||
|
import com.google.android.gms.location.LocationServices
|
||||||
|
import com.google.android.gms.location.Priority
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides location updates for EXIF GPS tagging.
|
||||||
|
*/
|
||||||
|
class LocationProvider(private val context: Context) {
|
||||||
|
|
||||||
|
private val fusedLocationClient: FusedLocationProviderClient =
|
||||||
|
LocationServices.getFusedLocationProviderClient(context)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a Flow of location updates.
|
||||||
|
* Updates are throttled to conserve battery - we only need periodic updates for photo tagging.
|
||||||
|
*/
|
||||||
|
fun locationFlow(): Flow<Location?> = callbackFlow {
|
||||||
|
if (!hasLocationPermission()) {
|
||||||
|
trySend(null)
|
||||||
|
awaitClose()
|
||||||
|
return@callbackFlow
|
||||||
|
}
|
||||||
|
|
||||||
|
val locationRequest = LocationRequest.Builder(
|
||||||
|
Priority.PRIORITY_BALANCED_POWER_ACCURACY,
|
||||||
|
30_000L // Update every 30 seconds
|
||||||
|
).apply {
|
||||||
|
setMinUpdateIntervalMillis(10_000L)
|
||||||
|
setMaxUpdateDelayMillis(60_000L)
|
||||||
|
}.build()
|
||||||
|
|
||||||
|
val callback = object : LocationCallback() {
|
||||||
|
override fun onLocationResult(result: LocationResult) {
|
||||||
|
result.lastLocation?.let { trySend(it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
fusedLocationClient.requestLocationUpdates(
|
||||||
|
locationRequest,
|
||||||
|
callback,
|
||||||
|
Looper.getMainLooper()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Also try to get last known location immediately
|
||||||
|
fusedLocationClient.lastLocation.addOnSuccessListener { location ->
|
||||||
|
location?.let { trySend(it) }
|
||||||
|
}
|
||||||
|
} catch (e: SecurityException) {
|
||||||
|
trySend(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
awaitClose {
|
||||||
|
fusedLocationClient.removeLocationUpdates(callback)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasLocationPermission(): Boolean {
|
||||||
|
return ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.ACCESS_FINE_LOCATION
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,85 @@
|
||||||
|
package no.naiv.tiltshift.util
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.view.OrientationEventListener
|
||||||
|
import android.view.Surface
|
||||||
|
import kotlinx.coroutines.channels.awaitClose
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.callbackFlow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Detects device orientation and provides it as a Flow.
|
||||||
|
* Used to properly orient captured images.
|
||||||
|
*/
|
||||||
|
class OrientationDetector(private val context: Context) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current device rotation as a Surface.ROTATION_* constant.
|
||||||
|
* This can be converted to degrees for EXIF orientation.
|
||||||
|
*/
|
||||||
|
fun orientationFlow(): Flow<Int> = callbackFlow {
|
||||||
|
val listener = object : OrientationEventListener(context) {
|
||||||
|
private var lastRotation = -1
|
||||||
|
|
||||||
|
override fun onOrientationChanged(orientation: Int) {
|
||||||
|
if (orientation == ORIENTATION_UNKNOWN) return
|
||||||
|
|
||||||
|
val rotation = when {
|
||||||
|
orientation >= 315 || orientation < 45 -> Surface.ROTATION_0
|
||||||
|
orientation >= 45 && orientation < 135 -> Surface.ROTATION_90
|
||||||
|
orientation >= 135 && orientation < 225 -> Surface.ROTATION_180
|
||||||
|
else -> Surface.ROTATION_270
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rotation != lastRotation) {
|
||||||
|
lastRotation = rotation
|
||||||
|
trySend(rotation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
listener.enable()
|
||||||
|
trySend(Surface.ROTATION_0) // Initial value
|
||||||
|
|
||||||
|
awaitClose {
|
||||||
|
listener.disable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Converts Surface rotation to degrees for EXIF.
|
||||||
|
*/
|
||||||
|
fun rotationToDegrees(rotation: Int): Int {
|
||||||
|
return when (rotation) {
|
||||||
|
Surface.ROTATION_0 -> 0
|
||||||
|
Surface.ROTATION_90 -> 90
|
||||||
|
Surface.ROTATION_180 -> 180
|
||||||
|
Surface.ROTATION_270 -> 270
|
||||||
|
else -> 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts degrees to EXIF orientation constant.
|
||||||
|
*/
|
||||||
|
fun degreesToExifOrientation(degrees: Int, isFrontCamera: Boolean): Int {
|
||||||
|
return when {
|
||||||
|
isFrontCamera -> when (degrees) {
|
||||||
|
0 -> android.media.ExifInterface.ORIENTATION_NORMAL
|
||||||
|
90 -> android.media.ExifInterface.ORIENTATION_ROTATE_270
|
||||||
|
180 -> android.media.ExifInterface.ORIENTATION_ROTATE_180
|
||||||
|
270 -> android.media.ExifInterface.ORIENTATION_ROTATE_90
|
||||||
|
else -> android.media.ExifInterface.ORIENTATION_NORMAL
|
||||||
|
}
|
||||||
|
else -> when (degrees) {
|
||||||
|
0 -> android.media.ExifInterface.ORIENTATION_NORMAL
|
||||||
|
90 -> android.media.ExifInterface.ORIENTATION_ROTATE_90
|
||||||
|
180 -> android.media.ExifInterface.ORIENTATION_ROTATE_180
|
||||||
|
270 -> android.media.ExifInterface.ORIENTATION_ROTATE_270
|
||||||
|
else -> android.media.ExifInterface.ORIENTATION_NORMAL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
10
app/src/main/res/drawable/ic_launcher_background.xml
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
<path
|
||||||
|
android:fillColor="#212121"
|
||||||
|
android:pathData="M0,0h108v108h-108z"/>
|
||||||
|
</vector>
|
||||||
49
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
49
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="108dp"
|
||||||
|
android:height="108dp"
|
||||||
|
android:viewportWidth="108"
|
||||||
|
android:viewportHeight="108">
|
||||||
|
|
||||||
|
<!-- Camera body -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFF"
|
||||||
|
android:pathData="M30,40 L78,40 Q82,40 82,44 L82,72 Q82,76 78,76 L30,76 Q26,76 26,72 L26,44 Q26,40 30,40 Z"/>
|
||||||
|
|
||||||
|
<!-- Camera lens -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#424242"
|
||||||
|
android:pathData="M54,58 m-14,0 a14,14 0,1 1,28 0 a14,14 0,1 1,-28 0"/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#616161"
|
||||||
|
android:pathData="M54,58 m-10,0 a10,10 0,1 1,20 0 a10,10 0,1 1,-20 0"/>
|
||||||
|
|
||||||
|
<!-- Lens reflection -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#90CAF9"
|
||||||
|
android:pathData="M48,52 Q52,48 58,52 Q54,56 48,52"/>
|
||||||
|
|
||||||
|
<!-- Flash -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFB300"
|
||||||
|
android:pathData="M70,44 L76,44 L76,50 L70,50 Z"/>
|
||||||
|
|
||||||
|
<!-- Tilt-shift blur indication (gradient bars) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#80FFFFFF"
|
||||||
|
android:pathData="M26,32 L82,32 L82,36 L26,36 Z"/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#40FFFFFF"
|
||||||
|
android:pathData="M26,28 L82,28 L82,30 L26,30 Z"/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#80FFFFFF"
|
||||||
|
android:pathData="M26,80 L82,80 L82,84 L26,84 Z"/>
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#40FFFFFF"
|
||||||
|
android:pathData="M26,86 L82,86 L82,88 L26,88 Z"/>
|
||||||
|
|
||||||
|
</vector>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<background android:drawable="@drawable/ic_launcher_background"/>
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
|
||||||
|
</adaptive-icon>
|
||||||
90
app/src/main/res/raw/tiltshift_fragment.glsl
Normal file
90
app/src/main/res/raw/tiltshift_fragment.glsl
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
// Fragment shader for tilt-shift effect
|
||||||
|
// Applies gradient blur based on distance from focus line
|
||||||
|
|
||||||
|
#extension GL_OES_EGL_image_external : require
|
||||||
|
|
||||||
|
precision mediump float;
|
||||||
|
|
||||||
|
// Camera texture (external texture for camera preview)
|
||||||
|
uniform samplerExternalOES uTexture;
|
||||||
|
|
||||||
|
// Effect parameters
|
||||||
|
uniform float uAngle; // Rotation angle in radians
|
||||||
|
uniform float uPosition; // Center position of focus (0-1)
|
||||||
|
uniform float uSize; // Size of in-focus region (0-1)
|
||||||
|
uniform float uBlurAmount; // Maximum blur intensity (0-1)
|
||||||
|
uniform vec2 uResolution; // Texture resolution for proper sampling
|
||||||
|
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
|
||||||
|
// Blur kernel size (must be odd)
|
||||||
|
const int KERNEL_SIZE = 9;
|
||||||
|
const float KERNEL_HALF = 4.0;
|
||||||
|
|
||||||
|
// Gaussian weights for 9-tap blur (sigma ~= 2.0)
|
||||||
|
const float weights[9] = float[](
|
||||||
|
0.0162, 0.0540, 0.1216, 0.1933, 0.2258,
|
||||||
|
0.1933, 0.1216, 0.0540, 0.0162
|
||||||
|
);
|
||||||
|
|
||||||
|
// Calculate signed distance from the focus line
|
||||||
|
float focusDistance(vec2 uv) {
|
||||||
|
// Rotate coordinate system around center
|
||||||
|
vec2 center = vec2(0.5, uPosition);
|
||||||
|
vec2 rotated = uv - center;
|
||||||
|
|
||||||
|
float cosA = cos(uAngle);
|
||||||
|
float sinA = sin(uAngle);
|
||||||
|
|
||||||
|
// After rotation, measure vertical distance from center line
|
||||||
|
float rotatedY = -rotated.x * sinA + rotated.y * cosA;
|
||||||
|
|
||||||
|
return abs(rotatedY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate blur factor based on distance from focus
|
||||||
|
float blurFactor(float dist) {
|
||||||
|
// Smooth transition from in-focus to blurred
|
||||||
|
float halfSize = uSize * 0.5;
|
||||||
|
float transitionSize = halfSize * 0.5;
|
||||||
|
|
||||||
|
if (dist < halfSize) {
|
||||||
|
return 0.0; // In focus region
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smooth falloff using smoothstep
|
||||||
|
float normalizedDist = (dist - halfSize) / transitionSize;
|
||||||
|
return smoothstep(0.0, 1.0, normalizedDist) * uBlurAmount;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample with Gaussian blur
|
||||||
|
vec4 sampleBlurred(vec2 uv, float blur) {
|
||||||
|
if (blur < 0.01) {
|
||||||
|
return texture2D(uTexture, uv);
|
||||||
|
}
|
||||||
|
|
||||||
|
vec4 color = vec4(0.0);
|
||||||
|
vec2 texelSize = 1.0 / uResolution;
|
||||||
|
|
||||||
|
// Blur direction perpendicular to focus line
|
||||||
|
float blurAngle = uAngle + 1.5707963; // +90 degrees
|
||||||
|
vec2 blurDir = vec2(cos(blurAngle), sin(blurAngle));
|
||||||
|
|
||||||
|
// Scale blur radius by blur amount
|
||||||
|
float radius = blur * 20.0;
|
||||||
|
|
||||||
|
for (int i = 0; i < KERNEL_SIZE; i++) {
|
||||||
|
float offset = (float(i) - KERNEL_HALF);
|
||||||
|
vec2 samplePos = uv + blurDir * texelSize * offset * radius;
|
||||||
|
color += texture2D(uTexture, samplePos) * weights[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
return color;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
float dist = focusDistance(vTexCoord);
|
||||||
|
float blur = blurFactor(dist);
|
||||||
|
|
||||||
|
gl_FragColor = sampleBlurred(vTexCoord, blur);
|
||||||
|
}
|
||||||
12
app/src/main/res/raw/tiltshift_vertex.glsl
Normal file
12
app/src/main/res/raw/tiltshift_vertex.glsl
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
// Vertex shader for tilt-shift effect
|
||||||
|
// Passes through position and calculates texture coordinates
|
||||||
|
|
||||||
|
attribute vec4 aPosition;
|
||||||
|
attribute vec2 aTexCoord;
|
||||||
|
|
||||||
|
varying vec2 vTexCoord;
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
gl_Position = aPosition;
|
||||||
|
vTexCoord = aTexCoord;
|
||||||
|
}
|
||||||
6
app/src/main/res/values/colors.xml
Normal file
6
app/src/main/res/values/colors.xml
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="white">#FFFFFFFF</color>
|
||||||
|
<color name="black">#FF000000</color>
|
||||||
|
<color name="tiltshift_accent">#FFFFB300</color>
|
||||||
|
</resources>
|
||||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">Tilt-Shift Camera</string>
|
||||||
|
</resources>
|
||||||
8
app/src/main/res/values/themes.xml
Normal file
8
app/src/main/res/values/themes.xml
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.TiltShiftCamera" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||||
|
<item name="android:windowLayoutInDisplayCutoutMode">shortEdges</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
6
build.gradle.kts
Normal file
6
build.gradle.kts
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
// Top-level build file where you can add configuration options common to all sub-projects/modules.
|
||||||
|
plugins {
|
||||||
|
alias(libs.plugins.android.application) apply false
|
||||||
|
alias(libs.plugins.kotlin.android) apply false
|
||||||
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
|
}
|
||||||
5
gradle.properties
Normal file
5
gradle.properties
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Project-wide Gradle settings.
|
||||||
|
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||||
|
android.useAndroidX=true
|
||||||
|
kotlin.code.style=official
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
44
gradle/libs.versions.toml
Normal file
44
gradle/libs.versions.toml
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
[versions]
|
||||||
|
agp = "8.7.3"
|
||||||
|
kotlin = "2.0.21"
|
||||||
|
coreKtx = "1.15.0"
|
||||||
|
lifecycleRuntimeKtx = "2.8.7"
|
||||||
|
activityCompose = "1.9.3"
|
||||||
|
composeBom = "2024.12.01"
|
||||||
|
camerax = "1.4.1"
|
||||||
|
accompanist = "0.36.0"
|
||||||
|
exifinterface = "1.3.7"
|
||||||
|
playServicesLocation = "21.3.0"
|
||||||
|
|
||||||
|
[libraries]
|
||||||
|
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
|
||||||
|
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
|
||||||
|
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
|
||||||
|
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
|
||||||
|
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
|
||||||
|
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
|
||||||
|
androidx-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
|
||||||
|
androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
|
||||||
|
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
|
||||||
|
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
|
||||||
|
|
||||||
|
# CameraX
|
||||||
|
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
|
||||||
|
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
|
||||||
|
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
|
||||||
|
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" }
|
||||||
|
|
||||||
|
# EXIF
|
||||||
|
androidx-exifinterface = { group = "androidx.exifinterface", name = "exifinterface", version.ref = "exifinterface" }
|
||||||
|
|
||||||
|
# Location
|
||||||
|
play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "playServicesLocation" }
|
||||||
|
|
||||||
|
# Accompanist for permissions
|
||||||
|
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
|
||||||
|
|
||||||
|
[plugins]
|
||||||
|
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||||
|
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
|
||||||
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.9-bin.zip
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
176
gradlew
vendored
Executable file
176
gradlew
vendored
Executable file
|
|
@ -0,0 +1,176 @@
|
||||||
|
#!/usr/bin/env sh
|
||||||
|
|
||||||
|
##############################################################################
|
||||||
|
##
|
||||||
|
## Gradle start up script for UN*X
|
||||||
|
##
|
||||||
|
##############################################################################
|
||||||
|
|
||||||
|
# Attempt to set APP_HOME
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
PRG="$0"
|
||||||
|
# Need this for relative symlinks.
|
||||||
|
while [ -h "$PRG" ] ; do
|
||||||
|
ls=`ls -ld "$PRG"`
|
||||||
|
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||||
|
if expr "$link" : '/.*' > /dev/null; then
|
||||||
|
PRG="$link"
|
||||||
|
else
|
||||||
|
PRG=`dirname "$PRG"`"/$link"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
SAVED="`pwd`"
|
||||||
|
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||||
|
APP_HOME="`pwd -P`"
|
||||||
|
cd "$SAVED" >/dev/null
|
||||||
|
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=`basename "$0"`
|
||||||
|
|
||||||
|
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
DEFAULT_JVM_OPTS=""
|
||||||
|
|
||||||
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
|
MAX_FD="maximum"
|
||||||
|
|
||||||
|
warn () {
|
||||||
|
echo "$*"
|
||||||
|
}
|
||||||
|
|
||||||
|
die () {
|
||||||
|
echo
|
||||||
|
echo "$*"
|
||||||
|
echo
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# OS specific support (must be 'true' or 'false').
|
||||||
|
cygwin=false
|
||||||
|
msys=false
|
||||||
|
darwin=false
|
||||||
|
nonstop=false
|
||||||
|
case "`uname`" in
|
||||||
|
CYGWIN* )
|
||||||
|
cygwin=true
|
||||||
|
;;
|
||||||
|
Darwin* )
|
||||||
|
darwin=true
|
||||||
|
;;
|
||||||
|
MINGW* )
|
||||||
|
msys=true
|
||||||
|
;;
|
||||||
|
NONSTOP* )
|
||||||
|
nonstop=true
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Determine the Java command to use to start the JVM.
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||||
|
# IBM's JDK on AIX uses strange locations for the executables
|
||||||
|
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||||
|
else
|
||||||
|
JAVACMD="$JAVA_HOME/bin/java"
|
||||||
|
fi
|
||||||
|
if [ ! -x "$JAVACMD" ] ; then
|
||||||
|
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
JAVACMD="java"
|
||||||
|
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
|
||||||
|
Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
location of your Java installation."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Increase the maximum file descriptors if we can.
|
||||||
|
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||||
|
MAX_FD_LIMIT=`ulimit -H -n`
|
||||||
|
if [ $? -eq 0 ] ; then
|
||||||
|
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||||
|
MAX_FD="$MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
ulimit -n $MAX_FD
|
||||||
|
if [ $? -ne 0 ] ; then
|
||||||
|
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Darwin, add options to specify how the application appears in the dock
|
||||||
|
if $darwin; then
|
||||||
|
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# For Cygwin, switch paths to Windows format before running java
|
||||||
|
if $cygwin ; then
|
||||||
|
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||||
|
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||||
|
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||||
|
|
||||||
|
# We build the pattern for arguments to be converted via cygpath
|
||||||
|
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||||
|
SEP=""
|
||||||
|
for dir in $ROOTDIRSRAW ; do
|
||||||
|
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||||
|
SEP="|"
|
||||||
|
done
|
||||||
|
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||||
|
# Add a user-defined pattern to the cygpath arguments
|
||||||
|
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||||
|
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||||
|
fi
|
||||||
|
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||||
|
i=0
|
||||||
|
for arg in "$@" ; do
|
||||||
|
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||||
|
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||||
|
|
||||||
|
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||||
|
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||||
|
else
|
||||||
|
eval `echo args$i`="\"$arg\""
|
||||||
|
fi
|
||||||
|
i=$((i+1))
|
||||||
|
done
|
||||||
|
case $i in
|
||||||
|
(0) set -- ;;
|
||||||
|
(1) set -- "$args0" ;;
|
||||||
|
(2) set -- "$args0" "$args1" ;;
|
||||||
|
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||||
|
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||||
|
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||||
|
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||||
|
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||||
|
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||||
|
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Escape application args
|
||||||
|
save () {
|
||||||
|
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||||
|
echo " "
|
||||||
|
}
|
||||||
|
APP_ARGS=$(save "$@")
|
||||||
|
|
||||||
|
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||||
|
if $JAVACMD --add-opens java.base/java.lang=ALL-UNNAMED -version ; then
|
||||||
|
DEFAULT_JVM_OPTS="--add-opens java.base/java.lang=ALL-UNNAMED $DEFAULT_JVM_OPTS"
|
||||||
|
fi
|
||||||
|
|
||||||
|
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||||
|
|
||||||
|
# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
|
||||||
|
if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$JAVACMD" "$@"
|
||||||
84
gradlew.bat
vendored
Normal file
84
gradlew.bat
vendored
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
@if "%DEBUG%" == "" @echo off
|
||||||
|
@rem ##########################################################################
|
||||||
|
@rem
|
||||||
|
@rem Gradle startup script for Windows
|
||||||
|
@rem
|
||||||
|
@rem ##########################################################################
|
||||||
|
|
||||||
|
@rem Set local scope for the variables with windows NT shell
|
||||||
|
if "%OS%"=="Windows_NT" setlocal
|
||||||
|
|
||||||
|
set DIRNAME=%~dp0
|
||||||
|
if "%DIRNAME%" == "" set DIRNAME=.
|
||||||
|
set APP_BASE_NAME=%~n0
|
||||||
|
set APP_HOME=%DIRNAME%
|
||||||
|
|
||||||
|
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||||
|
set DEFAULT_JVM_OPTS=
|
||||||
|
|
||||||
|
@rem Find java.exe
|
||||||
|
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||||
|
|
||||||
|
set JAVA_EXE=java.exe
|
||||||
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
|
if "%ERRORLEVEL%" == "0" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:findJavaFromJavaHome
|
||||||
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
|
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||||
|
|
||||||
|
if exist "%JAVA_EXE%" goto init
|
||||||
|
|
||||||
|
echo.
|
||||||
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||||
|
echo.
|
||||||
|
echo Please set the JAVA_HOME variable in your environment to match the
|
||||||
|
echo location of your Java installation.
|
||||||
|
|
||||||
|
goto fail
|
||||||
|
|
||||||
|
:init
|
||||||
|
@rem Get command-line arguments, handling Windows variants
|
||||||
|
|
||||||
|
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||||
|
|
||||||
|
:win9xME_args
|
||||||
|
@rem Slurp the command line arguments.
|
||||||
|
set CMD_LINE_ARGS=
|
||||||
|
set _SKIP=2
|
||||||
|
|
||||||
|
:win9xME_args_slurp
|
||||||
|
if "x%~1" == "x" goto execute
|
||||||
|
|
||||||
|
set CMD_LINE_ARGS=%*
|
||||||
|
|
||||||
|
:execute
|
||||||
|
@rem Setup the command line
|
||||||
|
|
||||||
|
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||||
|
|
||||||
|
@rem Execute Gradle
|
||||||
|
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||||
|
|
||||||
|
:end
|
||||||
|
@rem End local scope for the variables with windows NT shell
|
||||||
|
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||||
|
|
||||||
|
:fail
|
||||||
|
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||||
|
rem the _cmd.exe /c_ return code!
|
||||||
|
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||||
|
exit /b 1
|
||||||
|
|
||||||
|
:mainEnd
|
||||||
|
if "%OS%"=="Windows_NT" endlocal
|
||||||
|
|
||||||
|
:omega
|
||||||
24
settings.gradle.kts
Normal file
24
settings.gradle.kts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google {
|
||||||
|
content {
|
||||||
|
includeGroupByRegex("com\\.android.*")
|
||||||
|
includeGroupByRegex("com\\.google.*")
|
||||||
|
includeGroupByRegex("androidx.*")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "TiltShiftCamera"
|
||||||
|
include(":app")
|
||||||
Loading…
Add table
Add a link
Reference in a new issue