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

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