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:
2026-03-06 18:14:27 +00:00
commit 1660696d7f
32 changed files with 1962 additions and 0 deletions

45
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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

View 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 = "../.."
}

View 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>

View 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>

View File

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

View 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>

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View 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>

View 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>

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

View File

@@ -0,0 +1,2 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true

View 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

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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);
});
}