wireless camera app for Android
Stream a phone's camera to a tablet over WiFi/hotspot. WebSocket transport with native YUV-to-JPEG encoding, UDP auto-discovery, remote control (zoom, flash, camera switch, rotate, mirror) from the viewer.
This commit is contained in:
45
.gitignore
vendored
Normal file
45
.gitignore
vendored
Normal file
@@ -0,0 +1,45 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.build/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins-dependencies
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
/coverage/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
30
.metadata
Normal file
30
.metadata
Normal file
@@ -0,0 +1,30 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: "44a626f4f0027bc38a46dc68aed5964b05a83c18"
|
||||
channel: "stable"
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
- platform: android
|
||||
create_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
base_revision: 44a626f4f0027bc38a46dc68aed5964b05a83c18
|
||||
|
||||
# User provided section
|
||||
|
||||
# List of Local paths (relative to this file) that should be
|
||||
# ignored by the migrate tool.
|
||||
#
|
||||
# Files that are not part of the templates will be ignored by default.
|
||||
unmanaged_files:
|
||||
- 'lib/main.dart'
|
||||
- 'ios/Runner.xcodeproj/project.pbxproj'
|
||||
17
README.md
Normal file
17
README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# external_cam
|
||||
|
||||
A new Flutter project.
|
||||
|
||||
## Getting Started
|
||||
|
||||
This project is a starting point for a Flutter application.
|
||||
|
||||
A few resources to get you started if this is your first Flutter project:
|
||||
|
||||
- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
|
||||
- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
|
||||
- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
|
||||
|
||||
For help getting started with Flutter development, view the
|
||||
[online documentation](https://docs.flutter.dev/), which offers tutorials,
|
||||
samples, guidance on mobile development, and a full API reference.
|
||||
28
analysis_options.yaml
Normal file
28
analysis_options.yaml
Normal file
@@ -0,0 +1,28 @@
|
||||
# This file configures the analyzer, which statically analyzes Dart code to
|
||||
# check for errors, warnings, and lints.
|
||||
#
|
||||
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
|
||||
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
|
||||
# invoked from the command line by running `flutter analyze`.
|
||||
|
||||
# The following line activates a set of recommended lints for Flutter apps,
|
||||
# packages, and plugins designed to encourage good coding practices.
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
# The lint rules applied to this project can be customized in the
|
||||
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
|
||||
# included above or to enable additional rules. A list of all available lints
|
||||
# and their documentation is published at https://dart.dev/lints.
|
||||
#
|
||||
# Instead of disabling a lint rule for the entire project in the
|
||||
# section below, it can also be suppressed for a single line of code
|
||||
# or a specific dart file by using the `// ignore: name_of_lint` and
|
||||
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
|
||||
# producing the lint.
|
||||
rules:
|
||||
# avoid_print: false # Uncomment to disable the `avoid_print` rule
|
||||
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
|
||||
|
||||
# Additional information about this file can be found at
|
||||
# https://dart.dev/guides/language/analysis-options
|
||||
14
android/.gitignore
vendored
Normal file
14
android/.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
.cxx/
|
||||
|
||||
# Remember to never publicly share your keystore.
|
||||
# See https://flutter.dev/to/reference-keystore
|
||||
key.properties
|
||||
**/*.keystore
|
||||
**/*.jks
|
||||
44
android/app/build.gradle.kts
Normal file
44
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,44 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("kotlin-android")
|
||||
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.kschappell.external_cam"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
applicationId = "com.kschappell.external_cam"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = flutter.minSdkVersion
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
// TODO: Add your own signing config for the release build.
|
||||
// Signing with the debug keys for now, so `flutter run --release` works.
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
7
android/app/src/debug/AndroidManifest.xml
Normal file
7
android/app/src/debug/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
41
android/app/src/main/AndroidManifest.xml
Normal file
41
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,41 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
|
||||
<application
|
||||
android:label="External Cam"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity=""
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
</intent>
|
||||
</queries>
|
||||
</manifest>
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.kschappell.external_cam
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.ImageFormat
|
||||
import android.graphics.Rect
|
||||
import android.graphics.YuvImage
|
||||
import android.net.wifi.WifiManager
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.ByteArrayOutputStream
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
private var multicastLock: WifiManager.MulticastLock? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
acquireMulticastLock()
|
||||
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "com.kschappell.external_cam/jpeg")
|
||||
.setMethodCallHandler { call, result ->
|
||||
if (call.method == "encodeJpeg") {
|
||||
result.success(
|
||||
encodeYuvToJpeg(
|
||||
width = call.argument("width")!!,
|
||||
height = call.argument("height")!!,
|
||||
yBuffer = call.argument("y")!!,
|
||||
uBuffer = call.argument("u")!!,
|
||||
vBuffer = call.argument("v")!!,
|
||||
yRowStride = call.argument("yRowStride")!!,
|
||||
uvRowStride = call.argument("uvRowStride")!!,
|
||||
uvPixelStride = call.argument("uvPixelStride")!!,
|
||||
quality = call.argument("quality") ?: 70,
|
||||
)
|
||||
)
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun acquireMulticastLock() {
|
||||
val wifi = applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
multicastLock = wifi.createMulticastLock("external_cam").apply {
|
||||
setReferenceCounted(true)
|
||||
acquire()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
multicastLock?.release()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun encodeYuvToJpeg(
|
||||
width: Int,
|
||||
height: Int,
|
||||
yBuffer: ByteArray,
|
||||
uBuffer: ByteArray,
|
||||
vBuffer: ByteArray,
|
||||
yRowStride: Int,
|
||||
uvRowStride: Int,
|
||||
uvPixelStride: Int,
|
||||
quality: Int,
|
||||
): ByteArray {
|
||||
val nv21 = ByteArray(width * height * 3 / 2)
|
||||
var pos = 0
|
||||
|
||||
if (yRowStride == width) {
|
||||
System.arraycopy(yBuffer, 0, nv21, 0, width * height)
|
||||
pos = width * height
|
||||
} else {
|
||||
for (row in 0 until height) {
|
||||
System.arraycopy(yBuffer, row * yRowStride, nv21, pos, width)
|
||||
pos += width
|
||||
}
|
||||
}
|
||||
|
||||
val uvHeight = height / 2
|
||||
val uvWidth = width / 2
|
||||
val uvSize = uvWidth * uvHeight * 2
|
||||
if (uvPixelStride == 2 && uvRowStride == width && vBuffer.size >= uvSize) {
|
||||
System.arraycopy(vBuffer, 0, nv21, pos, uvSize)
|
||||
} else {
|
||||
for (row in 0 until uvHeight) {
|
||||
val offset = row * uvRowStride
|
||||
for (col in 0 until uvWidth) {
|
||||
val uvOffset = offset + col * uvPixelStride
|
||||
nv21[pos++] = vBuffer[uvOffset]
|
||||
nv21[pos++] = uBuffer[uvOffset]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val yuvImage = YuvImage(nv21, ImageFormat.NV21, width, height, null)
|
||||
val stream = ByteArrayOutputStream(width * height / 4)
|
||||
yuvImage.compressToJpeg(Rect(0, 0, width, height), quality, stream)
|
||||
return stream.toByteArray()
|
||||
}
|
||||
}
|
||||
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
12
android/app/src/main/res/drawable-v21/launch_background.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
12
android/app/src/main/res/drawable/launch_background.xml
Normal file
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
<bitmap
|
||||
android:gravity="center"
|
||||
android:src="@mipmap/launch_image" />
|
||||
</item> -->
|
||||
</layer-list>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 544 B |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 442 B |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 721 B |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
18
android/app/src/main/res/values-night/styles.xml
Normal file
18
android/app/src/main/res/values-night/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
18
android/app/src/main/res/values/styles.xml
Normal file
18
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
Flutter UI initializes, as well as behind your Flutter UI while its
|
||||
running.
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
7
android/app/src/profile/AndroidManifest.xml
Normal file
7
android/app/src/profile/AndroidManifest.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<!-- The INTERNET permission is required for development. Specifically,
|
||||
the Flutter tool needs it to communicate with the running application
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
24
android/build.gradle.kts
Normal file
24
android/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
val newBuildDir: Directory =
|
||||
rootProject.layout.buildDirectory
|
||||
.dir("../../build")
|
||||
.get()
|
||||
rootProject.layout.buildDirectory.value(newBuildDir)
|
||||
|
||||
subprojects {
|
||||
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
|
||||
project.layout.buildDirectory.value(newSubprojectBuildDir)
|
||||
}
|
||||
subprojects {
|
||||
project.evaluationDependsOn(":app")
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.layout.buildDirectory)
|
||||
}
|
||||
2
android/gradle.properties
Normal file
2
android/gradle.properties
Normal file
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-all.zip
|
||||
26
android/settings.gradle.kts
Normal file
26
android/settings.gradle.kts
Normal file
@@ -0,0 +1,26 @@
|
||||
pluginManagement {
|
||||
val flutterSdkPath =
|
||||
run {
|
||||
val properties = java.util.Properties()
|
||||
file("local.properties").inputStream().use { properties.load(it) }
|
||||
val flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
|
||||
flutterSdkPath
|
||||
}
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.11.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.2.20" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
324
lib/camera_screen.dart
Normal file
324
lib/camera_screen.dart
Normal file
@@ -0,0 +1,324 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import 'package:external_cam/frame_server.dart';
|
||||
import 'package:external_cam/jpeg_encoder.dart';
|
||||
|
||||
class CameraScreen extends StatefulWidget {
|
||||
const CameraScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CameraScreen> createState() => _CameraScreenState();
|
||||
}
|
||||
|
||||
class _CameraScreenState extends State<CameraScreen> {
|
||||
CameraController? _controller;
|
||||
final _server = FrameServer();
|
||||
final _beacon = DiscoveryBeacon();
|
||||
bool _isStreaming = false;
|
||||
bool _isProcessing = false;
|
||||
bool _flashOn = false;
|
||||
String _error = '';
|
||||
List<String> _addresses = [];
|
||||
List<CameraDescription> _cameras = [];
|
||||
int _cameraIndex = 0;
|
||||
int _fps = 0;
|
||||
int _frameCount = 0;
|
||||
double _zoom = 1.0;
|
||||
double _minZoom = 1.0;
|
||||
double _maxZoom = 1.0;
|
||||
int _quality = 50;
|
||||
Timer? _fpsTimer;
|
||||
StreamSubscription<String>? _commandSub;
|
||||
StreamSubscription<void>? _connectSub;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
Future<void> _init() async {
|
||||
try {
|
||||
if (!await Permission.camera.request().isGranted) {
|
||||
setState(() => _error = 'Camera permission required');
|
||||
return;
|
||||
}
|
||||
|
||||
_cameras = await availableCameras();
|
||||
if (_cameras.isEmpty) {
|
||||
setState(() => _error = 'No cameras found');
|
||||
return;
|
||||
}
|
||||
|
||||
await _initCamera(_cameras[_cameraIndex]);
|
||||
await _startServer();
|
||||
} catch (error) {
|
||||
setState(() => _error = error.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initCamera(CameraDescription camera) async {
|
||||
await _controller?.dispose();
|
||||
_flashOn = false;
|
||||
_zoom = 1.0;
|
||||
final controller = CameraController(
|
||||
camera,
|
||||
ResolutionPreset.high,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
await controller.initialize();
|
||||
if (!mounted) return;
|
||||
|
||||
_controller = controller;
|
||||
_minZoom = await controller.getMinZoomLevel();
|
||||
_maxZoom = await controller.getMaxZoomLevel();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _startServer() async {
|
||||
await _server.start();
|
||||
await _beacon.start();
|
||||
_addresses = await _networkAddresses();
|
||||
|
||||
_commandSub = _server.commands.listen(_handleCommand);
|
||||
_connectSub = _server.clientConnected.listen((_) => _broadcastState());
|
||||
|
||||
_fpsTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fps = _frameCount;
|
||||
_frameCount = 0;
|
||||
});
|
||||
});
|
||||
|
||||
setState(() {});
|
||||
_startStreaming();
|
||||
}
|
||||
|
||||
void _handleCommand(String command) {
|
||||
if (command.startsWith('zoom:')) {
|
||||
final level = double.tryParse(command.substring(5));
|
||||
if (level != null) _setZoom(level);
|
||||
return;
|
||||
}
|
||||
if (command.startsWith('quality:')) {
|
||||
final q = int.tryParse(command.substring(8));
|
||||
if (q != null) _setQuality(q);
|
||||
return;
|
||||
}
|
||||
switch (command) {
|
||||
case 'switch_camera':
|
||||
_switchCamera();
|
||||
case 'toggle_flash':
|
||||
_toggleFlash();
|
||||
}
|
||||
}
|
||||
|
||||
void _broadcastState() {
|
||||
if (_cameras.isEmpty) return;
|
||||
final camera = _cameras[_cameraIndex];
|
||||
_server.sendText(
|
||||
jsonEncode({
|
||||
'cameras': _cameras.length,
|
||||
'current': _cameraIndex,
|
||||
'flash': _flashOn,
|
||||
'lens': camera.lensDirection.name,
|
||||
'name': camera.name,
|
||||
'zoom': _zoom,
|
||||
'minZoom': _minZoom,
|
||||
'maxZoom': _maxZoom,
|
||||
'quality': _quality,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
void _startStreaming() {
|
||||
if (_isStreaming || _controller == null) return;
|
||||
_controller!.startImageStream(_onFrame);
|
||||
_isStreaming = true;
|
||||
WakelockPlus.enable();
|
||||
}
|
||||
|
||||
void _stopStreaming() {
|
||||
if (!_isStreaming || _controller == null) return;
|
||||
_controller!.stopImageStream();
|
||||
_isStreaming = false;
|
||||
}
|
||||
|
||||
void _onFrame(CameraImage image) {
|
||||
if (_isProcessing || _server.clientCount == 0) return;
|
||||
_isProcessing = true;
|
||||
|
||||
encodeJpeg(image, quality: _quality).then((jpeg) {
|
||||
if (jpeg != null) {
|
||||
_server.sendFrame(jpeg);
|
||||
_frameCount++;
|
||||
}
|
||||
}).whenComplete(() {
|
||||
_isProcessing = false;
|
||||
});
|
||||
}
|
||||
|
||||
Future<List<String>> _networkAddresses() async {
|
||||
final interfaces = await NetworkInterface.list();
|
||||
final results = <String>[];
|
||||
for (final iface in interfaces) {
|
||||
for (final addr in iface.addresses) {
|
||||
if (addr.type != InternetAddressType.IPv4 || addr.isLoopback) continue;
|
||||
results.add('${addr.address} (${iface.name})');
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
Future<void> _switchCamera() async {
|
||||
if (_cameras.length < 2) return;
|
||||
_stopStreaming();
|
||||
_cameraIndex = (_cameraIndex + 1) % _cameras.length;
|
||||
await _initCamera(_cameras[_cameraIndex]);
|
||||
_startStreaming();
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
Future<void> _setZoom(double level) async {
|
||||
final controller = _controller;
|
||||
if (controller == null) return;
|
||||
|
||||
_zoom = level.clamp(_minZoom, _maxZoom);
|
||||
await controller.setZoomLevel(_zoom);
|
||||
setState(() {});
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
void _setQuality(int value) {
|
||||
_quality = value.clamp(10, 100);
|
||||
setState(() {});
|
||||
_broadcastState();
|
||||
}
|
||||
|
||||
Future<void> _toggleFlash() async {
|
||||
final controller = _controller;
|
||||
if (controller == null) return;
|
||||
|
||||
try {
|
||||
_stopStreaming();
|
||||
_flashOn = !_flashOn;
|
||||
await controller.setFlashMode(
|
||||
_flashOn ? FlashMode.torch : FlashMode.off,
|
||||
);
|
||||
} catch (_) {
|
||||
_flashOn = false;
|
||||
} finally {
|
||||
_startStreaming();
|
||||
setState(() {});
|
||||
_broadcastState();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fpsTimer?.cancel();
|
||||
_commandSub?.cancel();
|
||||
_connectSub?.cancel();
|
||||
_stopStreaming();
|
||||
_controller?.dispose();
|
||||
_beacon.stop();
|
||||
_server.stop();
|
||||
WakelockPlus.disable();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error.isNotEmpty) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Camera')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(_error, textAlign: TextAlign.center),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_controller?.value.isInitialized ?? false)
|
||||
Center(child: CameraPreview(_controller!)),
|
||||
SafeArea(
|
||||
child: Column(
|
||||
children: [_buildInfoBar(), const Spacer(), _buildControls()],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoBar() {
|
||||
final addressText =
|
||||
_addresses.isNotEmpty ? _addresses.join('\n') : 'No network';
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.circle,
|
||||
color: _server.clientCount > 0 ? Colors.green : Colors.red,
|
||||
size: 12,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(addressText, style: const TextStyle(fontSize: 13)),
|
||||
),
|
||||
Text('${_server.clientCount} viewers $_fps fps'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildControls() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
IconButton.filled(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
iconSize: 28,
|
||||
),
|
||||
if (_cameras.length > 1)
|
||||
IconButton.filled(
|
||||
onPressed: _switchCamera,
|
||||
icon: const Icon(Icons.flip_camera_android),
|
||||
iconSize: 28,
|
||||
),
|
||||
IconButton.filled(
|
||||
onPressed: _toggleFlash,
|
||||
icon: Icon(_flashOn ? Icons.flash_on : Icons.flash_off),
|
||||
iconSize: 28,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
182
lib/frame_server.dart
Normal file
182
lib/frame_server.dart
Normal file
@@ -0,0 +1,182 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
const serverPort = 8080;
|
||||
const discoveryPort = 8081;
|
||||
const _beacon = 'EXTCAM';
|
||||
|
||||
class FrameServer {
|
||||
HttpServer? _httpServer;
|
||||
final List<WebSocket> _clients = [];
|
||||
final _commands = StreamController<String>.broadcast();
|
||||
final _connections = StreamController<void>.broadcast();
|
||||
|
||||
int get clientCount => _clients.length;
|
||||
Stream<String> get commands => _commands.stream;
|
||||
Stream<void> get clientConnected => _connections.stream;
|
||||
|
||||
Future<void> start() async {
|
||||
_httpServer = await HttpServer.bind(InternetAddress.anyIPv4, serverPort);
|
||||
_httpServer!.listen(_handleRequest);
|
||||
}
|
||||
|
||||
void _handleRequest(HttpRequest request) {
|
||||
if (request.uri.path == '/stream') {
|
||||
_upgradeToWebSocket(request);
|
||||
} else {
|
||||
request.response
|
||||
..statusCode = HttpStatus.ok
|
||||
..headers.contentType = ContentType.json
|
||||
..write('{"clients":${_clients.length}}')
|
||||
..close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _upgradeToWebSocket(HttpRequest request) async {
|
||||
final socket = await WebSocketTransformer.upgrade(request);
|
||||
_clients.add(socket);
|
||||
_connections.add(null);
|
||||
socket.listen((data) {
|
||||
if (data is String) _commands.add(data);
|
||||
}, onDone: () => _clients.remove(socket));
|
||||
}
|
||||
|
||||
void sendFrame(Uint8List jpeg) {
|
||||
for (final client in _clients.toList()) {
|
||||
try {
|
||||
client.add(jpeg);
|
||||
} catch (_) {
|
||||
_clients.remove(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void sendText(String message) {
|
||||
for (final client in _clients.toList()) {
|
||||
try {
|
||||
client.add(message);
|
||||
} catch (_) {
|
||||
_clients.remove(client);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _commands.close();
|
||||
await _connections.close();
|
||||
for (final client in _clients.toList()) {
|
||||
await client.close();
|
||||
}
|
||||
_clients.clear();
|
||||
await _httpServer?.close();
|
||||
_httpServer = null;
|
||||
}
|
||||
}
|
||||
|
||||
class DiscoveryBeacon {
|
||||
RawDatagramSocket? _socket;
|
||||
Timer? _timer;
|
||||
|
||||
Future<void> start() async {
|
||||
_socket = await RawDatagramSocket.bind(InternetAddress.anyIPv4, 0);
|
||||
_socket!.broadcastEnabled = true;
|
||||
_sendBroadcast();
|
||||
_timer = Timer.periodic(
|
||||
const Duration(seconds: 1),
|
||||
(_) => _sendBroadcast(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendBroadcast() async {
|
||||
final data = utf8.encode(_beacon);
|
||||
final socket = _socket;
|
||||
if (socket == null) return;
|
||||
|
||||
// global broadcast
|
||||
socket.send(data, InternetAddress('255.255.255.255'), discoveryPort);
|
||||
|
||||
// subnet-specific broadcasts (needed on hotspot/tethering)
|
||||
final interfaces = await NetworkInterface.list();
|
||||
for (final iface in interfaces) {
|
||||
for (final addr in iface.addresses) {
|
||||
if (addr.type != InternetAddressType.IPv4 || addr.isLoopback) continue;
|
||||
final parts = addr.address.split('.');
|
||||
final subnetBroadcast = '${parts[0]}.${parts[1]}.${parts[2]}.255';
|
||||
socket.send(data, InternetAddress(subnetBroadcast), discoveryPort);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_socket?.close();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> discoverServer({
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
final results = await Future.wait([
|
||||
_discoverViaBroadcast(timeout),
|
||||
_discoverViaProbe(),
|
||||
]);
|
||||
return results.firstWhere((r) => r != null, orElse: () => null);
|
||||
}
|
||||
|
||||
Future<String?> _discoverViaBroadcast(Duration timeout) async {
|
||||
final socket = await RawDatagramSocket.bind(
|
||||
InternetAddress.anyIPv4,
|
||||
discoveryPort,
|
||||
);
|
||||
try {
|
||||
await for (final event in socket.timeout(timeout)) {
|
||||
if (event == RawSocketEvent.read) {
|
||||
final datagram = socket.receive();
|
||||
if (datagram == null) continue;
|
||||
final message = utf8.decode(datagram.data);
|
||||
if (message == _beacon) return datagram.address.address;
|
||||
}
|
||||
}
|
||||
} on TimeoutException {
|
||||
// no server found
|
||||
} finally {
|
||||
socket.close();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<String?> _discoverViaProbe() async {
|
||||
final interfaces = await NetworkInterface.list();
|
||||
final candidates = <String>{};
|
||||
|
||||
for (final iface in interfaces) {
|
||||
for (final addr in iface.addresses) {
|
||||
if (addr.type != InternetAddressType.IPv4 || addr.isLoopback) continue;
|
||||
final parts = addr.address.split('.');
|
||||
final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
|
||||
for (var suffix = 1; suffix <= 254; suffix++) {
|
||||
final candidate = '$subnet.$suffix';
|
||||
if (candidate != addr.address) candidates.add(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final futures = candidates.map((ip) async {
|
||||
try {
|
||||
final socket = await Socket.connect(
|
||||
ip,
|
||||
serverPort,
|
||||
timeout: const Duration(milliseconds: 800),
|
||||
);
|
||||
socket.destroy();
|
||||
return ip;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
final results = await Future.wait(futures);
|
||||
return results.whereType<String>().firstOrNull;
|
||||
}
|
||||
97
lib/home_screen.dart
Normal file
97
lib/home_screen.dart
Normal file
@@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:external_cam/camera_screen.dart';
|
||||
import 'package:external_cam/viewer_screen.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
const HomeScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('External Cam')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_ModeCard(
|
||||
icon: Icons.camera_alt,
|
||||
title: 'Camera',
|
||||
subtitle: 'Stream this device\'s camera over USB',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const CameraScreen()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_ModeCard(
|
||||
icon: Icons.tv,
|
||||
title: 'Viewer',
|
||||
subtitle: 'View the camera stream from a connected device',
|
||||
onTap: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const ViewerScreen()),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Text(
|
||||
'Connect both devices with USB-C, then enable\n'
|
||||
'USB tethering on the camera device.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ModeCard extends StatelessWidget {
|
||||
const _ModeCard({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 48),
|
||||
const SizedBox(width: 24),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
Text(subtitle),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/jpeg_encoder.dart
Normal file
18
lib/jpeg_encoder.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
const _channel = MethodChannel('com.kschappell.external_cam/jpeg');
|
||||
|
||||
Future<Uint8List?> encodeJpeg(CameraImage image, {int quality = 50}) async {
|
||||
return _channel.invokeMethod<Uint8List>('encodeJpeg', {
|
||||
'width': image.width,
|
||||
'height': image.height,
|
||||
'y': image.planes[0].bytes,
|
||||
'u': image.planes[1].bytes,
|
||||
'v': image.planes[2].bytes,
|
||||
'yRowStride': image.planes[0].bytesPerRow,
|
||||
'uvRowStride': image.planes[1].bytesPerRow,
|
||||
'uvPixelStride': image.planes[1].bytesPerPixel,
|
||||
'quality': quality,
|
||||
});
|
||||
}
|
||||
25
lib/main.dart
Normal file
25
lib/main.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:external_cam/home_screen.dart';
|
||||
|
||||
void main() {
|
||||
runApp(const ExternalCamApp());
|
||||
}
|
||||
|
||||
class ExternalCamApp extends StatelessWidget {
|
||||
const ExternalCamApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MaterialApp(
|
||||
title: 'External Cam',
|
||||
theme: ThemeData.dark(useMaterial3: true).copyWith(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: Colors.teal,
|
||||
brightness: Brightness.dark,
|
||||
),
|
||||
),
|
||||
home: const HomeScreen(),
|
||||
);
|
||||
}
|
||||
}
|
||||
390
lib/viewer_screen.dart
Normal file
390
lib/viewer_screen.dart
Normal file
@@ -0,0 +1,390 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||
|
||||
import 'package:external_cam/frame_server.dart';
|
||||
|
||||
class ViewerScreen extends StatefulWidget {
|
||||
const ViewerScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ViewerScreen> createState() => _ViewerScreenState();
|
||||
}
|
||||
|
||||
class _ViewerScreenState extends State<ViewerScreen> {
|
||||
final _ipController = TextEditingController();
|
||||
WebSocket? _socket;
|
||||
Uint8List? _currentFrame;
|
||||
bool _isConnected = false;
|
||||
bool _isSearching = true;
|
||||
bool _isConnecting = false;
|
||||
int _fps = 0;
|
||||
int _frameCount = 0;
|
||||
int _rotation = 0;
|
||||
bool _mirrored = false;
|
||||
Timer? _fpsTimer;
|
||||
|
||||
int _cameraCount = 0;
|
||||
bool _flashOn = false;
|
||||
String _lensDirection = '';
|
||||
String _cameraName = '';
|
||||
double _zoom = 1.0;
|
||||
double _minZoom = 1.0;
|
||||
double _maxZoom = 1.0;
|
||||
int _quality = 50;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SystemChrome.setPreferredOrientations([
|
||||
DeviceOrientation.landscapeLeft,
|
||||
DeviceOrientation.landscapeRight,
|
||||
]);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
_autoDiscover();
|
||||
}
|
||||
|
||||
Future<void> _autoDiscover() async {
|
||||
setState(() => _isSearching = true);
|
||||
final ip = await discoverServer();
|
||||
if (!mounted) return;
|
||||
|
||||
if (ip != null) {
|
||||
_ipController.text = ip;
|
||||
_connectTo(ip);
|
||||
} else {
|
||||
setState(() => _isSearching = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _connectTo(String ip) async {
|
||||
if (_isConnecting) return;
|
||||
setState(() {
|
||||
_isConnecting = true;
|
||||
_isSearching = false;
|
||||
});
|
||||
|
||||
try {
|
||||
_socket = await WebSocket.connect(
|
||||
'ws://$ip:$serverPort/stream',
|
||||
).timeout(const Duration(seconds: 5));
|
||||
|
||||
_fpsTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_fps = _frameCount;
|
||||
_frameCount = 0;
|
||||
});
|
||||
});
|
||||
|
||||
_socket!.listen(
|
||||
_onData,
|
||||
onDone: _onDisconnected,
|
||||
onError: (_) => _onDisconnected(),
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_isConnected = true;
|
||||
_isConnecting = false;
|
||||
});
|
||||
WakelockPlus.enable();
|
||||
} catch (error) {
|
||||
setState(() => _isConnecting = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('Connection failed: $error')));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _connect() {
|
||||
final ip = _ipController.text.trim();
|
||||
if (ip.isNotEmpty) _connectTo(ip);
|
||||
}
|
||||
|
||||
void _onData(dynamic data) {
|
||||
if (data is List<int>) {
|
||||
setState(() {
|
||||
_currentFrame = Uint8List.fromList(data);
|
||||
_frameCount++;
|
||||
});
|
||||
} else if (data is String) {
|
||||
_handleState(data);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleState(String json) {
|
||||
try {
|
||||
final state = jsonDecode(json) as Map<String, dynamic>;
|
||||
setState(() {
|
||||
_cameraCount = state['cameras'] as int? ?? 0;
|
||||
_flashOn = state['flash'] as bool? ?? false;
|
||||
_lensDirection = state['lens'] as String? ?? '';
|
||||
_cameraName = state['name'] as String? ?? '';
|
||||
_zoom = (state['zoom'] as num?)?.toDouble() ?? 1.0;
|
||||
_minZoom = (state['minZoom'] as num?)?.toDouble() ?? 1.0;
|
||||
_maxZoom = (state['maxZoom'] as num?)?.toDouble() ?? 1.0;
|
||||
_quality = state['quality'] as int? ?? 50;
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _sendCommand(String command) => _socket?.add(command);
|
||||
|
||||
void _onDisconnected() {
|
||||
_fpsTimer?.cancel();
|
||||
_fpsTimer = null;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_isConnected = false;
|
||||
_currentFrame = null;
|
||||
});
|
||||
WakelockPlus.disable();
|
||||
}
|
||||
|
||||
void _disconnect() {
|
||||
_socket?.close();
|
||||
_onDisconnected();
|
||||
}
|
||||
|
||||
void _cycleRotation() {
|
||||
setState(() => _rotation = (_rotation + 1) % 4);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_fpsTimer?.cancel();
|
||||
_socket?.close();
|
||||
_ipController.dispose();
|
||||
WakelockPlus.disable();
|
||||
SystemChrome.setPreferredOrientations(DeviceOrientation.values);
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isConnected) return _buildViewer();
|
||||
return _buildConnectScreen();
|
||||
}
|
||||
|
||||
Widget _buildConnectScreen() {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Viewer')),
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_isSearching) ...[
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Searching for camera...'),
|
||||
] else ...[
|
||||
TextField(
|
||||
controller: _ipController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Camera IP address',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (_) => _connect(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: _isConnecting ? null : _connect,
|
||||
icon: _isConnecting
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.play_arrow),
|
||||
label: Text(_isConnecting ? 'Connecting...' : 'Connect'),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isConnecting ? null : _autoDiscover,
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('Search'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildViewer() {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (_currentFrame != null)
|
||||
Center(
|
||||
child: Transform.flip(
|
||||
flipX: _mirrored,
|
||||
child: RotatedBox(
|
||||
quarterTurns: _rotation,
|
||||
child: Image.memory(
|
||||
_currentFrame!,
|
||||
gaplessPlayback: true,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildTopBar(),
|
||||
_buildBottomControls(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTopBar() {
|
||||
return Positioned(
|
||||
top: 16,
|
||||
right: 16,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black54,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.circle, color: Colors.green, size: 12),
|
||||
const SizedBox(width: 8),
|
||||
Text('$_fps fps'),
|
||||
if (_cameraName.isNotEmpty) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$_lensDirection $_cameraName',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<double> get _availableZoomPresets {
|
||||
const presets = [0.5, 1.0, 2.0, 3.0, 5.0, 10.0];
|
||||
return presets.where((z) => z >= _minZoom && z <= _maxZoom).toList();
|
||||
}
|
||||
|
||||
Widget _buildBottomControls() {
|
||||
return Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Colors.black54, Colors.transparent],
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: _disconnect,
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
if (_cameraCount > 1)
|
||||
IconButton(
|
||||
onPressed: () => _sendCommand('switch_camera'),
|
||||
icon: const Icon(Icons.flip_camera_android, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => _sendCommand('toggle_flash'),
|
||||
icon: Icon(
|
||||
_flashOn ? Icons.flash_on : Icons.flash_off,
|
||||
size: 20,
|
||||
),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _cycleRotation,
|
||||
icon: const Icon(Icons.rotate_right, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => setState(() => _mirrored = !_mirrored),
|
||||
icon: const Icon(Icons.flip, size: 20),
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
const Spacer(),
|
||||
if (_availableZoomPresets.length > 1)
|
||||
..._buildChipGroup(
|
||||
_availableZoomPresets.map((level) {
|
||||
final active = (_zoom - level).abs() < 0.1;
|
||||
final label = level == level.roundToDouble()
|
||||
? '${level.toInt()}x'
|
||||
: '${level}x';
|
||||
return _chip(label, active, () => _sendCommand('zoom:$level'));
|
||||
}),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
..._buildChipGroup(
|
||||
[30, 50, 70, 90].map(
|
||||
(q) => _chip('$q%', _quality == q, () => _sendCommand('quality:$q')),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildChipGroup(Iterable<Widget> chips) => chips.toList();
|
||||
|
||||
Widget _chip(String label, bool active, VoidCallback onTap) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? Colors.white24 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: active ? Colors.white70 : Colors.white30,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: active ? Colors.white : Colors.white70,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
442
pubspec.lock
Normal file
442
pubspec.lock
Normal file
@@ -0,0 +1,442 @@
|
||||
# Generated by pub
|
||||
# See https://dart.dev/tools/pub/glossary#lockfile
|
||||
packages:
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: async
|
||||
sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.0"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
camera:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: camera
|
||||
sha256: "4142a19a38e388d3bab444227636610ba88982e36dff4552d5191a86f65dc437"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.4"
|
||||
camera_android_camerax:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_android_camerax
|
||||
sha256: "8516fe308bc341a5067fb1a48edff0ddfa57c0d3cdcc9dbe7ceca3ba119e2577"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.30"
|
||||
camera_avfoundation:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_avfoundation
|
||||
sha256: "11b4aee2f5e5e038982e152b4a342c749b414aa27857899d20f4323e94cb5f0b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.23+2"
|
||||
camera_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_platform_interface
|
||||
sha256: "98cfc9357e04bad617671b4c1f78a597f25f08003089dd94050709ae54effc63"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.12.0"
|
||||
camera_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: camera_web
|
||||
sha256: "57f49a635c8bf249d07fb95eb693d7e4dda6796dedb3777f9127fb54847beba7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+3"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
clock:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
collection:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
cross_file:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: cross_file
|
||||
sha256: "28bb3ae56f117b5aec029d702a90f57d285cd975c3c5c281eaca38dbc47c5937"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.5+2"
|
||||
dbus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dbus
|
||||
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ffi
|
||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
flutter:
|
||||
dependency: "direct main"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.0.0"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
sha256: ee8068e0e1cd16c4a82714119918efdeed33b3ba7772c54b5d094ab53f9b7fd1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.33"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_web_plugins:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
leak_tracker:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lints
|
||||
sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.1.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.18"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.17.0"
|
||||
package_info_plus:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus
|
||||
sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.0.0"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.1"
|
||||
path:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.2"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.8"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
source_span:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
stream_channel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "19a78f63e83d3a61f00826d09bc2f60e191bf3504183c001262be6ac75589fb8"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.8"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
wakelock_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: wakelock_plus
|
||||
sha256: "9296d40c9adbedaba95d1e704f4e0b434be446e2792948d0e4aa977048104228"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
wakelock_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wakelock_plus_platform_interface
|
||||
sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
win32:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32
|
||||
sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.6.1"
|
||||
sdks:
|
||||
dart: ">=3.11.0 <4.0.0"
|
||||
flutter: ">=3.35.0"
|
||||
22
pubspec.yaml
Normal file
22
pubspec.yaml
Normal file
@@ -0,0 +1,22 @@
|
||||
name: external_cam
|
||||
description: View your phone's camera on your tablet over USB-C.
|
||||
publish_to: 'none'
|
||||
version: 1.0.0+1
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
camera: ^0.11.0
|
||||
permission_handler: ^11.0.0
|
||||
wakelock_plus: ^1.0.0
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
12
test/widget_test.dart
Normal file
12
test/widget_test.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:external_cam/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('app renders home screen', (tester) async {
|
||||
await tester.pumpWidget(const ExternalCamApp());
|
||||
expect(find.text('External Cam'), findsOneWidget);
|
||||
expect(find.text('Camera'), findsOneWidget);
|
||||
expect(find.text('Viewer'), findsOneWidget);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user