live_media_stream_sdk 0.1.0
live_media_stream_sdk: ^0.1.0 copied to clipboard
Self-hosted Flutter live streaming with native Opus and VP8/VP9 encoding, WebM output, advanced camera/audio controls, and any binary WebSocket server.
example/lib/main.dart
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:live_media_stream_sdk/live_media_stream_sdk.dart';
import 'package:permission_handler/permission_handler.dart';
void main() {
runApp(const LiveMediaStreamExampleApp());
}
class LiveMediaStreamExampleApp extends StatelessWidget {
const LiveMediaStreamExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Live Media Stream SDK',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const LiveMediaStreamExamplePage(),
);
}
}
class LiveMediaStreamExamplePage extends StatefulWidget {
const LiveMediaStreamExamplePage({super.key});
@override
State<LiveMediaStreamExamplePage> createState() =>
_LiveMediaStreamExamplePageState();
}
class _LiveMediaStreamExamplePageState
extends State<LiveMediaStreamExamplePage> {
// Replace this value with your live media server endpoint.
static const String _webSocketUrl = 'wss://example.com/live';
final LiveMediaStreamController _controller = LiveMediaStreamController();
late final WebSocketLiveMediaTransport _transport;
StreamSubscription<LiveStreamEvent>? _eventSubscription;
StreamSubscription<LiveMediaPacket>? _packetSubscription;
StreamSubscription<LiveStreamStatistics>? _statisticsSubscription;
StreamSubscription<LiveSocketMessage>? _socketMessageSubscription;
bool _isInitializing = false;
bool _isStreaming = false;
bool _isPaused = false;
bool _isWebSocketConnected = false;
int? _videoTextureId;
String _status = 'SDK hazırlanıyor';
String? _errorMessage;
int _packetCount = 0;
int _audioPacketCount = 0;
int _videoPacketCount = 0;
int _webmPacketCount = 0;
int _receivedBytes = 0;
int _audioBytes = 0;
int _videoBytes = 0;
int _webmBytes = 0;
LiveStreamStatistics? _statistics;
// Stream configuration
// Stream configuration
StreamMediaMode _mediaMode = StreamMediaMode.audioOnly;
StreamOutputMode _outputMode = StreamOutputMode.separateAudioVideo;
// Android foreground service configuration
bool _enableForegroundService = true;
String _foregroundNotificationTitle = 'MYS canlı yayın';
String _foregroundNotificationText = 'Ses ve video yayını devam ediyor';
// Video configuration
VideoCodec _videoCodec = VideoCodec.vp8;
StreamResolution _resolution = StreamResolution.p720;
CameraPosition _cameraPosition = CameraPosition.back;
QualityMode _qualityMode = QualityMode.automatic;
int _framesPerSecond = 30;
int _videoBitrate = 1500000;
String _cameraStatus = 'Kamera kapalı';
// Audio monitoring
double _audioLevelDb = -96;
double _audioPeak = 0;
bool _isSilent = true;
bool _isClipping = false;
int _clippedSamples = 0;
String _audioFocusState = 'unknown';
String _audioDeviceStatus = 'Değişiklik yok';
// Audio configuration
bool _automaticGainControl = true;
bool _echoCancellation = true;
bool _noiseSuppression = true;
bool _voiceActivityDetection = true;
double _gain = 1.0;
double _silenceThresholdDb = -65;
int _vadHangoverMilliseconds = 250;
int _sampleRate = 48000;
int _audioBitrate = 64000;
AudioChannelMode _channelMode = AudioChannelMode.mono;
List<AudioInputDevice> _audioInputDevices = const [];
AudioInputDevice? _selectedAudioInputDevice;
// Socket metadata. These values may be changed while streaming.
String _groupName = 'mosque_42';
String _senderConnectionId = 'flutter_connection_123';
String _receiverConnectionId = 'orange_pi_connection_456';
int _userId = 15;
String _userType = 'imam';
final Map<String, dynamic> _customHeaders = <String, dynamic>{
'deviceId': 'mys_device_01',
'mosqueId': 42,
'sessionId': 'live_session_987',
};
bool get _hasAudio => _mediaMode.hasAudio;
bool get _hasVideo => _mediaMode.hasVideo;
@override
void initState() {
super.initState();
_transport = WebSocketLiveMediaTransport(
uri: Uri.parse(_webSocketUrl),
reconnectDelay: const Duration(seconds: 2),
maxReconnectAttempts: 5,
);
_listenController();
_listenTransport();
unawaited(_prepareSdk());
}
Future<void> _prepareSdk() async {
try {
await _controller.initialize();
_videoTextureId = await _controller.getVideoTextureId();
await _loadAudioInputDevices();
if (!mounted) return;
setState(() => _status = 'SDK hazır');
} catch (error, stackTrace) {
debugPrint('SDK hazırlama hatası: $error');
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_status = 'SDK başlatılamadı';
_errorMessage = error.toString();
});
}
}
void _listenController() {
_eventSubscription = _controller.events.listen(
(event) {
if (!mounted) return;
setState(() {
if (event is LiveStreamConnected) {
_status = 'Yayın başladı';
_isStreaming = true;
_isPaused = false;
_errorMessage = null;
} else if (event is LiveStreamDisconnected) {
_status = 'Yayın durduruldu';
_isStreaming = false;
_isPaused = false;
_isWebSocketConnected = false;
_cameraStatus = 'Kamera kapalı';
} else if (event is LiveStreamError) {
_status = 'Yayın hatası';
_errorMessage = '${event.code}: ${event.message}';
} else if (event is AudioLevelChanged) {
_audioLevelDb = event.decibels;
_audioPeak = event.peak.clamp(0.0, 1.0).toDouble();
_isSilent = event.isSilent;
if (!_isClipping) {
_isClipping = event.peak >= 0.99;
}
} else if (event is AudioPeakDetected) {
_audioLevelDb = event.decibels;
_audioPeak = event.peak.clamp(0.0, 1.0).toDouble();
_isClipping = true;
_clippedSamples = event.clippedSamples;
} else if (event is AudioFocusChanged) {
_audioFocusState = event.state;
if (event.state == 'gained' && _isStreaming) {
_status = 'Audio focus geri geldi';
} else if (event.state == 'lost' ||
event.state == 'lostTransient') {
_status = 'Audio focus kaybedildi';
} else if (event.state == 'duck') {
_status = 'Audio focus geçici olarak azaltıldı';
}
} else if (event is AudioDeviceChanged) {
_audioDeviceStatus =
'${event.name} • ${event.deviceType} • ${event.change}';
unawaited(_loadAudioInputDevices());
} else if (event is CameraOpened) {
_cameraStatus =
'${event.position} kamera • ${event.width}×${event.height} • '
'${event.codec.toUpperCase()} • ID ${event.cameraId}';
} else {
debugPrint('Bilinmeyen SDK eventi: ${event.runtimeType}');
}
});
},
onError: (Object error, StackTrace stackTrace) {
if (!mounted) return;
setState(() {
_status = 'Event stream hatası';
_errorMessage = error.toString();
});
},
);
_packetSubscription = _controller.packets.listen(
(packet) {
if (!mounted) return;
setState(() {
_packetCount++;
_receivedBytes += packet.data.length;
if (packet is AudioPacket) {
_audioPacketCount++;
_audioBytes += packet.data.length;
} else if (packet is VideoPacket) {
_videoPacketCount++;
_videoBytes += packet.data.length;
} else if (packet is WebmPacket) {
_webmPacketCount++;
_webmBytes += packet.data.length;
}
});
},
onError: (Object error, StackTrace stackTrace) {
if (!mounted) return;
setState(() => _errorMessage = 'Paket hatası: $error');
},
);
_statisticsSubscription = _controller.statistics.listen(
(statistics) {
if (!mounted) return;
setState(() {
_statistics = statistics;
_audioLevelDb = statistics.audioLevelDb;
_audioPeak = statistics.audioPeak.clamp(0.0, 1.0).toDouble();
_isSilent = statistics.isSilent;
_isClipping = statistics.isClipping;
if (statistics.clippedAudioFrames > 0) {
_clippedSamples = statistics.clippedAudioFrames;
}
});
},
onError: (Object error, StackTrace stackTrace) {
if (!mounted) return;
setState(() => _errorMessage = 'İstatistik hatası: $error');
},
);
}
void _listenTransport() {
_socketMessageSubscription = _transport.messages.listen(
(message) {
debugPrint(
'Sunucudan socket mesajı: action=${message.action}, '
'command=${message.command}, payload=${message.payload?.length ?? 0} byte',
);
},
onError: (Object error, StackTrace stackTrace) {
if (!mounted) return;
setState(() {
_isWebSocketConnected = false;
_errorMessage = 'WebSocket hatası: $error';
});
},
);
}
Future<void> _loadAudioInputDevices() async {
try {
final devices = await _controller.getAudioInputDevices();
if (!mounted) return;
AudioInputDevice? selected = _selectedAudioInputDevice;
if (selected != null) {
selected = devices.cast<AudioInputDevice?>().firstWhere(
(device) => device?.id == selected?.id,
orElse: () => null,
);
}
selected ??= devices.cast<AudioInputDevice?>().firstWhere(
(device) => device?.isDefault == true,
orElse: () => devices.isEmpty ? null : devices.first,
);
setState(() {
_audioInputDevices = devices;
_selectedAudioInputDevice = selected;
});
} catch (error) {
if (!mounted) return;
setState(() => _errorMessage = 'Mikrofonlar alınamadı: $error');
}
}
Future<bool> _requestPermission(
Permission permission,
String title,
String explanation,
) async {
var status = await permission.status;
if (status.isGranted) return true;
status = await permission.request();
if (status.isGranted) return true;
if (!mounted) return false;
if (status.isPermanentlyDenied) {
final openSettings = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text(title),
content: Text(explanation),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text('İptal'),
),
FilledButton(
onPressed: () => Navigator.pop(context, true),
child: const Text('Ayarları aç'),
),
],
),
);
if (openSettings == true) await openAppSettings();
}
return false;
}
Future<bool> _requestRequiredPermissions() async {
if (_hasAudio) {
final granted = await _requestPermission(
Permission.microphone,
'Mikrofon izni gerekli',
'Ses yayını için uygulama ayarlarından mikrofon iznini açmalısın.',
);
if (!granted) {
if (mounted) {
setState(() {
_status = 'Mikrofon izni verilmedi';
_errorMessage = 'Seçilen yayın modu mikrofon izni gerektiriyor.';
});
}
return false;
}
}
if (_hasVideo) {
final granted = await _requestPermission(
Permission.camera,
'Kamera izni gerekli',
'Video yayını için uygulama ayarlarından kamera iznini açmalısın.',
);
if (!granted) {
if (mounted) {
setState(() {
_status = 'Kamera izni verilmedi';
_errorMessage = 'Seçilen yayın modu kamera izni gerektiriyor.';
});
}
return false;
}
}
if (Platform.isAndroid && _enableForegroundService) {
final notificationStatus = await Permission.notification.status;
if (!notificationStatus.isGranted) {
await Permission.notification.request();
}
}
return true;
}
Future<void> _startStream() async {
if (_isInitializing || _isStreaming) return;
setState(() {
_isInitializing = true;
_status = 'İzinler kontrol ediliyor';
_errorMessage = null;
_packetCount = 0;
_audioPacketCount = 0;
_videoPacketCount = 0;
_webmPacketCount = 0;
_receivedBytes = 0;
_audioBytes = 0;
_videoBytes = 0;
_webmBytes = 0;
_statistics = null;
_audioLevelDb = -96;
_audioPeak = 0;
_isSilent = true;
_isClipping = false;
_clippedSamples = 0;
_cameraStatus = _hasVideo ? 'Kamera açılıyor' : 'Video kapalı';
});
try {
if (!await _requestRequiredPermissions()) return;
if (!mounted) return;
setState(() => _status = 'Medya yakalama başlatılıyor');
await _controller.start(
LiveStreamConfiguration(
transport: _transport,
socketMessageBuilder: (packet) {
final String command;
if (packet is AudioPacket) {
command = 'sendAudio';
} else if (packet is VideoPacket) {
command = 'sendVideo';
} else {
command = 'sendWebm';
}
return LiveSocketMessage.fromMediaPacket(
packet,
action: 'liveMedia',
command: command,
groupName: _groupName,
senderConnectionId: _senderConnectionId,
receiverConnectionId: _receiverConnectionId,
userId: _userId,
userType: _userType,
headers: Map<String, dynamic>.from(_customHeaders),
);
},
mediaMode: _mediaMode,
videoCodec: _videoCodec,
audioCodec: AudioCodec.opus,
resolution: _resolution,
outputMode: _outputMode,
cameraPosition: _cameraPosition,
qualityMode: _qualityMode,
framesPerSecond: _framesPerSecond,
videoBitrate: _videoBitrate,
audioBitrate: _audioBitrate,
enableForegroundService: _enableForegroundService,
foregroundNotificationTitle:
_foregroundNotificationTitle.trim().isEmpty
? 'Canlı yayın sürüyor'
: _foregroundNotificationTitle.trim(),
foregroundNotificationText: _foregroundNotificationText.trim().isEmpty
? 'Mikrofon ve kamera kullanılıyor'
: _foregroundNotificationText.trim(),
audioProcessing: AudioProcessingConfiguration(
automaticGainControl: _automaticGainControl,
echoCancellation: _echoCancellation,
noiseSuppression: _noiseSuppression,
voiceActivityDetection: _voiceActivityDetection,
inputDevice: _selectedAudioInputDevice,
gain: _gain,
channelMode: _channelMode,
sampleRate: _sampleRate,
silenceThresholdDb: _silenceThresholdDb,
vadHangoverMilliseconds: _vadHangoverMilliseconds,
),
),
);
if (!mounted) return;
setState(() {
_isStreaming = true;
_isPaused = false;
_isWebSocketConnected = _transport.isConnected;
_status = '${_mediaModeLabel(_mediaMode)} yayını aktif';
});
} catch (error, stackTrace) {
debugPrint('Yayın başlatma hatası: $error');
debugPrintStack(stackTrace: stackTrace);
if (!mounted) return;
setState(() {
_isStreaming = false;
_isPaused = false;
_isWebSocketConnected = false;
_status = 'Yayın başlatılamadı';
_errorMessage = error.toString();
_cameraStatus = 'Kamera açılamadı';
});
} finally {
if (mounted) setState(() => _isInitializing = false);
}
}
Future<void> _stopStream() async {
try {
setState(() {
_status = 'Yayın durduruluyor';
_errorMessage = null;
});
await _controller.stop();
if (!mounted) return;
setState(() {
_isStreaming = false;
_isPaused = false;
_isWebSocketConnected = false;
_audioLevelDb = -96;
_audioPeak = 0;
_isSilent = true;
_isClipping = false;
_cameraStatus = 'Kamera kapalı';
_status = 'Yayın durduruldu';
});
} catch (error) {
if (!mounted) return;
setState(() {
_status = 'Yayın durdurulamadı';
_errorMessage = error.toString();
});
}
}
Future<void> _pauseStream() async {
try {
await _controller.pause();
if (!mounted) return;
setState(() {
_isPaused = true;
_status = 'Yayın duraklatıldı';
});
} catch (error) {
if (mounted) setState(() => _errorMessage = error.toString());
}
}
Future<void> _resumeStream() async {
try {
await _controller.resume();
if (!mounted) return;
setState(() {
_isPaused = false;
_status = 'Yayın devam ediyor';
});
} catch (error) {
if (mounted) setState(() => _errorMessage = error.toString());
}
}
Future<void> _switchCamera() async {
final next = _cameraPosition == CameraPosition.back
? CameraPosition.front
: CameraPosition.back;
try {
await _controller.switchCamera(next);
if (!mounted) return;
setState(() {
_cameraPosition = next;
_status = '${_cameraLabel(next)} kameraya geçildi';
});
} catch (error) {
if (mounted) {
setState(() => _errorMessage = 'Kamera değiştirilemedi: $error');
}
}
}
Future<void> _requestKeyFrame() async {
try {
await _controller.requestVideoKeyFrame();
if (mounted) setState(() => _status = 'Keyframe istendi');
} catch (error) {
if (mounted) {
setState(() => _errorMessage = 'Keyframe istenemedi: $error');
}
}
}
Future<void> _applyVideoBitrate() async {
try {
await _controller.setVideoBitrate(_videoBitrate);
if (mounted) {
setState(
() => _status =
'Video bitrate güncellendi: ${_formatBitrate(_videoBitrate)}',
);
}
} catch (error) {
if (mounted) {
setState(() => _errorMessage = 'Bitrate güncellenemedi: $error');
}
}
}
@override
void dispose() {
_eventSubscription?.cancel();
_packetSubscription?.cancel();
_statisticsSubscription?.cancel();
_socketMessageSubscription?.cancel();
unawaited(_controller.dispose());
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Live Media Stream SDK'),
actions: [
IconButton(
onPressed: _isStreaming ? null : _loadAudioInputDevices,
tooltip: 'Mikrofonları yenile',
icon: const Icon(Icons.refresh_rounded),
),
],
),
body: SafeArea(
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildStatusCard(context),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
_buildErrorCard(context),
],
const SizedBox(height: 16),
_buildStreamSettingsCard(context),
if (Platform.isAndroid) ...[
const SizedBox(height: 16),
_buildForegroundServiceCard(context),
],
if (_hasVideo) ...[
const SizedBox(height: 16),
_buildVideoSettingsCard(context),
],
if (_hasAudio) ...[
const SizedBox(height: 16),
_buildAudioMeterCard(context),
const SizedBox(height: 16),
_buildAudioSettingsCard(context),
],
const SizedBox(height: 16),
_buildSocketMetadataCard(context),
const SizedBox(height: 16),
_buildStatisticsCard(context, _statistics),
const SizedBox(height: 24),
_buildControlButtons(),
],
),
),
);
}
Widget _buildStatusCard(BuildContext context) {
final icon = switch (_mediaMode) {
StreamMediaMode.audioOnly => Icons.mic_rounded,
StreamMediaMode.videoOnly => Icons.videocam_rounded,
StreamMediaMode.audioVideo => Icons.video_camera_front_rounded,
};
return Card(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
children: [
if (_hasVideo && _videoTextureId != null) ...[
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: ColoredBox(
color: Colors.black,
child: LiveMediaPreview(
textureId: _videoTextureId!,
aspectRatio: _resolution.width / _resolution.height,
quarterTurns: Platform.isIOS ? 1 : 0,
),
),
),
const SizedBox(height: 16),
],
Icon(
icon,
size: 72,
color: _isStreaming
? Colors.red
: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
Text(
_status,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
Text(
_isStreaming
? _outputMode == StreamOutputMode.webm
? 'Gerçek WebM byte stream parçaları WebSocket üzerinden gönderiliyor.'
: '${_mediaModeLabel(_mediaMode)} paketleri WebSocket üzerinden gönderiliyor.'
: 'Yayın türünü ve codec ayarlarını seçip yayını başlat.',
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
_StatusChip(
label: _isWebSocketConnected
? 'WebSocket bağlı'
: 'WebSocket kapalı',
icon: _isWebSocketConnected
? Icons.cloud_done_rounded
: Icons.cloud_off_rounded,
),
if (_hasVideo)
_StatusChip(
label: _cameraStatus,
icon: Icons.videocam_rounded,
),
if (_hasAudio)
_StatusChip(
label: 'Focus: $_audioFocusState',
icon: Icons.hearing_rounded,
),
if (_hasAudio)
_StatusChip(
label: _audioDeviceStatus,
icon: Icons.mic_external_on_rounded,
),
],
),
],
),
),
);
}
Widget _buildErrorCard(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.all(16),
child: Text(
_errorMessage!,
style: TextStyle(
color: Theme.of(context).colorScheme.onErrorContainer,
),
),
),
);
}
Widget _buildStreamSettingsCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Yayın ayarları',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
DropdownButtonFormField<StreamMediaMode>(
initialValue: _mediaMode,
decoration: const InputDecoration(
labelText: 'Yayın türü',
border: OutlineInputBorder(),
),
items: StreamMediaMode.values
.map(
(mode) => DropdownMenuItem(
value: mode,
child: Text(_mediaModeLabel(mode)),
),
)
.toList(),
onChanged: _isStreaming
? null
: (value) {
if (value == null) return;
setState(() => _mediaMode = value);
},
),
const SizedBox(height: 12),
DropdownButtonFormField<StreamOutputMode>(
initialValue: _outputMode,
decoration: const InputDecoration(
labelText: 'Çıkış biçimi',
border: OutlineInputBorder(),
),
items: StreamOutputMode.values
.map(
(mode) => DropdownMenuItem(
value: mode,
child: Text(switch (mode) {
StreamOutputMode.webm => 'WebM',
StreamOutputMode.separateAudioVideo =>
'Ayrı audio/video dosyaları',
_ => 'Ayrı audio/video paketleri',
}),
),
)
.toList(),
onChanged: _isStreaming
? null
: (value) {
if (value == null) return;
setState(() => _outputMode = value);
},
),
if (_outputMode == StreamOutputMode.webm) ...[
const SizedBox(height: 10),
const Text(
'Android üzerinde VP8/VP9 video ve Opus ses gerçek WebM container '
'içinde mux edilir. WebmPacket parçalarını sunucuya geliş sırasını '
'bozmadan gönder.',
),
],
],
),
),
);
}
Widget _buildForegroundServiceCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Android foreground service',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'Uygulama arka plana geçtiğinde kamera ve mikrofon yayınının '
'devam etmesini sağlar.',
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Foreground service kullan'),
subtitle: const Text(
'Yayın başlatılırken Android kalıcı bildirim gösterir.',
),
value: _enableForegroundService,
onChanged: _isStreaming
? null
: (value) => setState(() => _enableForegroundService = value),
),
if (_enableForegroundService) ...[
const SizedBox(height: 8),
TextFormField(
initialValue: _foregroundNotificationTitle,
enabled: !_isStreaming,
decoration: const InputDecoration(
labelText: 'Bildirim başlığı',
border: OutlineInputBorder(),
),
onChanged: (value) => _foregroundNotificationTitle = value,
),
const SizedBox(height: 12),
TextFormField(
initialValue: _foregroundNotificationText,
enabled: !_isStreaming,
decoration: const InputDecoration(
labelText: 'Bildirim açıklaması',
border: OutlineInputBorder(),
),
onChanged: (value) => _foregroundNotificationText = value,
),
],
],
),
),
);
}
Widget _buildVideoSettingsCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Video ayarları',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: DropdownButtonFormField<VideoCodec>(
initialValue: _videoCodec,
decoration: const InputDecoration(
labelText: 'Codec',
border: OutlineInputBorder(),
),
items: VideoCodec.values
.map(
(codec) => DropdownMenuItem(
value: codec,
child: Text(codec.name.toUpperCase()),
),
)
.toList(),
onChanged: _isStreaming
? null
: (value) {
if (value != null) {
setState(() => _videoCodec = value);
}
},
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<StreamResolution>(
initialValue: _resolution,
decoration: const InputDecoration(
labelText: 'Çözünürlük',
border: OutlineInputBorder(),
),
items: StreamResolution.values
.map(
(resolution) => DropdownMenuItem(
value: resolution,
child: Text(
'${resolution.name.substring(1)}p (${resolution.width}×${resolution.height})',
),
),
)
.toList(),
onChanged: _isStreaming
? null
: (value) {
if (value != null) {
setState(() => _resolution = value);
}
},
),
),
],
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: DropdownButtonFormField<CameraPosition>(
initialValue: _cameraPosition,
decoration: const InputDecoration(
labelText: 'Kamera',
border: OutlineInputBorder(),
),
items: CameraPosition.values
.map(
(position) => DropdownMenuItem(
value: position,
child: Text(_cameraLabel(position)),
),
)
.toList(),
onChanged: _isStreaming
? null
: (value) {
if (value != null) {
setState(() => _cameraPosition = value);
}
},
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<QualityMode>(
initialValue: _qualityMode,
decoration: const InputDecoration(
labelText: 'Kalite modu',
border: OutlineInputBorder(),
),
items: QualityMode.values
.map(
(mode) => DropdownMenuItem(
value: mode,
child: Text(
mode == QualityMode.automatic
? 'Otomatik'
: 'Manuel',
),
),
)
.toList(),
onChanged: _isStreaming
? null
: (value) {
if (value != null) {
setState(() => _qualityMode = value);
}
},
),
),
],
),
const SizedBox(height: 16),
Text('FPS: $_framesPerSecond'),
Slider(
value: _framesPerSecond.toDouble(),
min: 10,
max: 60,
divisions: 10,
label: '$_framesPerSecond FPS',
onChanged: _isStreaming
? null
: (value) => setState(() => _framesPerSecond = value.round()),
),
Text('Video bitrate: ${_formatBitrate(_videoBitrate)}'),
Slider(
value: _videoBitrate.toDouble(),
min: 250000,
max: 8000000,
divisions: 31,
label: _formatBitrate(_videoBitrate),
onChanged: (value) =>
setState(() => _videoBitrate = value.round()),
onChangeEnd: _isStreaming ? (_) => _applyVideoBitrate() : null,
),
if (_isStreaming) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: _switchCamera,
icon: const Icon(Icons.cameraswitch_rounded),
label: const Text('Kamerayı değiştir'),
),
OutlinedButton.icon(
onPressed: _requestKeyFrame,
icon: const Icon(Icons.key_rounded),
label: const Text('Keyframe iste'),
),
OutlinedButton.icon(
onPressed: _applyVideoBitrate,
icon: const Icon(Icons.speed_rounded),
label: const Text('Bitrate uygula'),
),
],
),
],
],
),
),
);
}
Widget _buildAudioMeterCard(BuildContext context) {
final normalizedLevel = ((_audioLevelDb + 60) / 60)
.clamp(0.0, 1.0)
.toDouble();
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Canlı ses seviyesi',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 16),
LinearProgressIndicator(
value: normalizedLevel,
minHeight: 14,
borderRadius: BorderRadius.circular(10),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _MetricTile(
title: 'Seviye',
value: '${_audioLevelDb.toStringAsFixed(1)} dB',
),
),
Expanded(
child: _MetricTile(
title: 'Peak',
value: '${(_audioPeak * 100).toStringAsFixed(1)}%',
),
),
Expanded(
child: _MetricTile(
title: 'Durum',
value: _isClipping
? 'Clipping'
: (_isSilent ? 'Sessiz' : 'Konuşma'),
),
),
],
),
if (_isClipping) ...[
const SizedBox(height: 12),
Text(
'Ses seviyesi çok yüksek. Gain değerini azalt. Clipped: $_clippedSamples',
),
],
],
),
),
);
}
Widget _buildAudioSettingsCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Audio ayarları',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 12),
DropdownButtonFormField<AudioInputDevice>(
initialValue: _selectedAudioInputDevice,
decoration: const InputDecoration(
labelText: 'Mikrofon',
border: OutlineInputBorder(),
),
items: _audioInputDevices
.map(
(device) => DropdownMenuItem(
value: device,
child: Text(
'${device.name}${device.isDefault ? ' (Varsayılan)' : ''}',
overflow: TextOverflow.ellipsis,
),
),
)
.toList(),
onChanged: _isStreaming
? null
: (device) =>
setState(() => _selectedAudioInputDevice = device),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: DropdownButtonFormField<int>(
initialValue: _sampleRate,
decoration: const InputDecoration(
labelText: 'Sample rate',
border: OutlineInputBorder(),
),
items: const [8000, 12000, 16000, 24000, 48000]
.map(
(value) => DropdownMenuItem(
value: value,
child: Text('$value Hz'),
),
)
.toList(),
onChanged: _isStreaming
? null
: (value) {
if (value != null) {
setState(() => _sampleRate = value);
}
},
),
),
const SizedBox(width: 12),
Expanded(
child: DropdownButtonFormField<AudioChannelMode>(
initialValue: _channelMode,
decoration: const InputDecoration(
labelText: 'Kanal',
border: OutlineInputBorder(),
),
items: AudioChannelMode.values
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(value.name),
),
)
.toList(),
onChanged: _isStreaming
? null
: (value) {
if (value != null) {
setState(() => _channelMode = value);
}
},
),
),
],
),
const SizedBox(height: 12),
DropdownButtonFormField<int>(
initialValue: _audioBitrate,
decoration: const InputDecoration(
labelText: 'Audio bitrate',
border: OutlineInputBorder(),
),
items: const [16000, 24000, 32000, 48000, 64000, 96000, 128000]
.map(
(value) => DropdownMenuItem(
value: value,
child: Text(_formatBitrate(value)),
),
)
.toList(),
onChanged: _isStreaming
? null
: (value) {
if (value != null) setState(() => _audioBitrate = value);
},
),
const SizedBox(height: 16),
Text('Gain: ${_gain.toStringAsFixed(1)}x'),
Slider(
value: _gain,
min: 0,
max: 4,
divisions: 40,
label: '${_gain.toStringAsFixed(1)}x',
onChanged: _isStreaming
? null
: (value) => setState(() => _gain = value),
),
Text('VAD eşiği: ${_silenceThresholdDb.toStringAsFixed(0)} dB'),
Slider(
value: _silenceThresholdDb,
min: -80,
max: -20,
divisions: 60,
label: '${_silenceThresholdDb.toStringAsFixed(0)} dB',
onChanged: _isStreaming
? null
: (value) => setState(() => _silenceThresholdDb = value),
),
Text('VAD hangover: $_vadHangoverMilliseconds ms'),
Slider(
value: _vadHangoverMilliseconds.toDouble(),
min: 0,
max: 1000,
divisions: 20,
label: '$_vadHangoverMilliseconds ms',
onChanged: _isStreaming
? null
: (value) => setState(
() => _vadHangoverMilliseconds = value.round(),
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Automatic Gain Control'),
value: _automaticGainControl,
onChanged: _isStreaming
? null
: (value) => setState(() => _automaticGainControl = value),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Echo Cancellation'),
value: _echoCancellation,
onChanged: _isStreaming
? null
: (value) => setState(() => _echoCancellation = value),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Noise Suppression'),
value: _noiseSuppression,
onChanged: _isStreaming
? null
: (value) => setState(() => _noiseSuppression = value),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Voice Activity Detection'),
subtitle: const Text(
'Sessiz audio frame’lerini göndermeyebilir.',
),
value: _voiceActivityDetection,
onChanged: _isStreaming
? null
: (value) => setState(() => _voiceActivityDetection = value),
),
],
),
),
);
}
Widget _buildSocketMetadataCard(BuildContext context) {
return Card(
child: ExpansionTile(
title: const Text('Socket mesaj bilgileri'),
subtitle: const Text('Bu değerler yayın sırasında değiştirilebilir.'),
childrenPadding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
children: [
TextFormField(
initialValue: _groupName,
decoration: const InputDecoration(labelText: 'Group name'),
onChanged: (value) => _groupName = value,
),
TextFormField(
initialValue: _senderConnectionId,
decoration: const InputDecoration(
labelText: 'Sender connection ID',
),
onChanged: (value) => _senderConnectionId = value,
),
TextFormField(
initialValue: _receiverConnectionId,
decoration: const InputDecoration(
labelText: 'Receiver connection ID',
),
onChanged: (value) => _receiverConnectionId = value,
),
TextFormField(
initialValue: _userId.toString(),
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: 'User ID'),
onChanged: (value) => _userId = int.tryParse(value) ?? _userId,
),
TextFormField(
initialValue: _userType,
decoration: const InputDecoration(labelText: 'User type'),
onChanged: (value) => _userType = value,
),
],
),
);
}
Widget _buildStatisticsCard(
BuildContext context,
LiveStreamStatistics? statistics,
) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_InfoRow(
title: 'Controller',
value: _controller.isInitialized ? 'Başlatıldı' : 'Başlatılmadı',
),
_InfoRow(
title: 'Yayın',
value: _isStreaming
? (_isPaused ? 'Duraklatıldı' : 'Aktif')
: 'Kapalı',
),
_InfoRow(title: 'Mod', value: _mediaModeLabel(_mediaMode)),
_InfoRow(
title: 'WebSocket',
value: _isWebSocketConnected ? 'Bağlı' : 'Bağlı değil',
),
const _InfoRow(title: 'Sunucu', value: _webSocketUrl),
_InfoRow(title: 'Toplam paket', value: _packetCount.toString()),
_InfoRow(
title: 'Audio paket',
value: '$_audioPacketCount • ${_formatBytes(_audioBytes)}',
),
_InfoRow(
title: 'Video paket',
value: '$_videoPacketCount • ${_formatBytes(_videoBytes)}',
),
_InfoRow(
title: 'WebM paket',
value: '$_webmPacketCount • ${_formatBytes(_webmBytes)}',
),
_InfoRow(
title: 'Çıkış biçimi',
value: _outputModeLabel(_outputMode),
),
if (Platform.isAndroid)
_InfoRow(
title: 'Foreground service',
value: _enableForegroundService ? 'Aktif' : 'Kapalı',
),
_InfoRow(
title: 'Üretilen veri',
value: _formatBytes(_receivedBytes),
),
if (statistics != null) ...[
const Divider(),
_InfoRow(
title: 'CPU',
value: '%${statistics.cpuUsage.toStringAsFixed(1)}',
),
_InfoRow(
title: 'RAM',
value:
'${statistics.memoryUsageMegabytes.toStringAsFixed(1)} MB',
),
_InfoRow(
title: 'Upload',
value: _formatBitrate(statistics.uploadBitsPerSecond),
),
_InfoRow(
title: 'Encoded bitrate',
value: _formatBitrate(statistics.encodedBitsPerSecond),
),
if (_hasVideo) ...[
const Divider(),
_InfoRow(
title: 'Video codec',
value: statistics.videoCodec.isEmpty
? '-'
: statistics.videoCodec.toUpperCase(),
),
_InfoRow(
title: 'Video boyutu',
value: '${statistics.videoWidth}×${statistics.videoHeight}',
),
_InfoRow(
title: 'Gerçek FPS',
value: statistics.framesPerSecond.toStringAsFixed(1),
),
_InfoRow(
title: 'Video packet/s',
value: statistics.videoPacketsPerSecond.toString(),
),
_InfoRow(
title: 'Video encoded',
value: _formatBitrate(statistics.videoEncodedBitsPerSecond),
),
_InfoRow(
title: 'Toplam video paketi',
value: statistics.totalVideoPackets.toString(),
),
_InfoRow(
title: 'Video veri',
value: _formatBytes(statistics.totalVideoBytes),
),
_InfoRow(
title: 'Keyframe',
value: statistics.videoKeyFrames.toString(),
),
_InfoRow(
title: 'Düşürülen video frame',
value: statistics.droppedVideoFrames.toString(),
),
],
if (_hasAudio) ...[
const Divider(),
_InfoRow(
title: 'Audio packet/s',
value: statistics.audioPacketsPerSecond.toString(),
),
_InfoRow(
title: 'Toplam audio paketi',
value: statistics.totalAudioPackets.toString(),
),
_InfoRow(
title: 'Düşürülen audio frame',
value: statistics.droppedAudioFrames.toString(),
),
_InfoRow(
title: 'Clipped audio frame',
value: statistics.clippedAudioFrames.toString(),
),
_InfoRow(
title: 'AGC',
value: statistics.automaticGainControlEnabled
? 'Aktif'
: 'Kapalı',
),
_InfoRow(
title: 'AEC',
value: statistics.echoCancelerEnabled ? 'Aktif' : 'Kapalı',
),
_InfoRow(
title: 'NS',
value: statistics.noiseSuppressorEnabled ? 'Aktif' : 'Kapalı',
),
],
],
],
),
),
);
}
Widget _buildControlButtons() {
if (!_isStreaming) {
return FilledButton.icon(
onPressed: _isInitializing ? null : _startStream,
icon: _isInitializing
? const SizedBox.square(
dimension: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_arrow_rounded),
label: Text(
_isInitializing
? 'Başlatılıyor'
: '${_mediaModeLabel(_mediaMode)} yayınını başlat',
),
);
}
return Column(
children: [
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _isPaused ? _resumeStream : _pauseStream,
icon: Icon(
_isPaused ? Icons.play_arrow_rounded : Icons.pause_rounded,
),
label: Text(_isPaused ? 'Yayına devam et' : 'Yayını duraklat'),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.tonalIcon(
onPressed: _stopStream,
icon: const Icon(Icons.stop_rounded),
label: const Text('Yayını durdur'),
),
),
],
);
}
String _mediaModeLabel(StreamMediaMode mode) => switch (mode) {
StreamMediaMode.audioOnly => 'Sadece ses',
StreamMediaMode.videoOnly => 'Sadece video',
StreamMediaMode.audioVideo => 'Ses + video',
};
String _outputModeLabel(StreamOutputMode mode) => switch (mode) {
StreamOutputMode.webm => 'WebM',
StreamOutputMode.separateAudioVideo => 'Ayrı audio/video paketleri',
_ => 'Ayrı audio/video paketleri (eski)',
};
String _cameraLabel(CameraPosition position) =>
position == CameraPosition.front ? 'Ön kamera' : 'Arka kamera';
String _formatBytes(int bytes) {
if (bytes >= 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(2)} MB';
}
if (bytes >= 1024) return '${(bytes / 1024).toStringAsFixed(2)} KB';
return '$bytes byte';
}
String _formatBitrate(int bitsPerSecond) {
if (bitsPerSecond >= 1000000) {
return '${(bitsPerSecond / 1000000).toStringAsFixed(2)} Mbit/s';
}
if (bitsPerSecond >= 1000) {
return '${(bitsPerSecond / 1000).toStringAsFixed(1)} kbit/s';
}
return '$bitsPerSecond bit/s';
}
}
class _StatusChip extends StatelessWidget {
const _StatusChip({required this.label, required this.icon});
final String label;
final IconData icon;
@override
Widget build(BuildContext context) {
return Chip(
avatar: Icon(icon, size: 18),
label: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 280),
child: Text(label, overflow: TextOverflow.ellipsis),
),
);
}
}
class _MetricTile extends StatelessWidget {
const _MetricTile({required this.title, required this.value});
final String title;
final String value;
@override
Widget build(BuildContext context) {
return Column(
children: [
Text(
value,
style: Theme.of(context).textTheme.titleMedium,
textAlign: TextAlign.center,
),
const SizedBox(height: 4),
Text(
title,
style: Theme.of(context).textTheme.bodySmall,
textAlign: TextAlign.center,
),
],
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.title, required this.value});
final String title;
final String value;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: Row(
children: [
Expanded(child: Text(title)),
const SizedBox(width: 12),
Flexible(
child: Text(
value,
textAlign: TextAlign.end,
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
}
}