live_media_stream_sdk 0.4.1
live_media_stream_sdk: ^0.4.1 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> {
final LiveMediaStreamController _controller = LiveMediaStreamController();
final LiveMediaPlayerController _playerController =
LiveMediaPlayerController();
WebSocketLiveMediaTransport? _transport;
final TextEditingController _webSocketUrlController = TextEditingController(
text: '192.168.1.102:8080',
);
final TextEditingController _recordingFileNameController =
TextEditingController();
final TextEditingController _playerSourceController = TextEditingController();
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;
bool _enableTransport = false;
bool _enableRecording = true;
bool _overwriteRecording = false;
MediaRecordingResult? _lastRecording;
_PlayerSourceType _playerSourceType = _PlayerSourceType.lastRecording;
bool _playerAutoplay = true;
bool _isOpeningPlayer = false;
String? _playerError;
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
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 = 'room_42';
String _senderConnectionId = 'flutter_connection_123';
String _receiverConnectionId = 'orange_pi_connection_456';
int _userId = 15;
String _userType = 'publisher';
final Map<String, dynamic> _customHeaders = <String, dynamic>{
'deviceId': 'mys_device_01',
'siteId': 42,
'sessionId': 'live_session_987',
};
bool get _hasAudio => _mediaMode.hasAudio;
bool get _hasVideo => _mediaMode.hasVideo;
@override
void initState() {
super.initState();
_listenController();
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(WebSocketLiveMediaTransport transport) {
_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<WebSocketLiveMediaTransport> _createTransport() async {
final uri = _normalizeWebSocketUri(_webSocketUrlController.text);
await _socketMessageSubscription?.cancel();
_socketMessageSubscription = null;
await _transport?.dispose();
final transport = WebSocketLiveMediaTransport(
uri: uri,
reconnectDelay: const Duration(seconds: 2),
maxReconnectAttempts: 5,
);
_transport = transport;
_listenTransport(transport);
return transport;
}
Uri _normalizeWebSocketUri(String input) {
final value = input.trim();
if (value.isEmpty) {
throw const FormatException('WebSocket IP adresi veya URL gerekli.');
}
final schemeMatch = RegExp(
r'^([a-zA-Z][a-zA-Z0-9+.-]*)://',
).firstMatch(value);
if (schemeMatch != null) {
final entered = Uri.parse(value);
final scheme = schemeMatch.group(1)!.toLowerCase();
if (scheme == 'ws' || scheme == 'wss') return entered;
if (scheme == 'http' || scheme == 'https') {
return entered.replace(scheme: scheme == 'https' ? 'wss' : 'ws');
}
throw const FormatException(
'WebSocket adresi ws://, wss://, http:// veya https:// kullanmalı.',
);
}
final host = value.split('/').first.split(':').first.toLowerCase();
final isLocalAddress =
host == 'localhost' ||
RegExp(r'^\d{1,3}(\.\d{1,3}){3}$').hasMatch(host);
return Uri.parse('${isLocalAddress ? 'ws' : 'wss'}://$value');
}
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');
final transport = _enableTransport ? await _createTransport() : null;
await _controller.start(
LiveStreamConfiguration(
transport: transport,
socketMessageBuilder: !_enableTransport
? null
: (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),
);
},
recording: !_enableRecording
? null
: RecordingConfiguration(
fileName: _recordingFileNameController.text.trim().isEmpty
? null
: _recordingFileNameController.text.trim(),
overwriteExisting: _overwriteRecording,
),
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 ?? false;
_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;
});
final stopResult = await _controller.stop();
if (!mounted) return;
setState(() {
_lastRecording = stopResult.recording;
if (stopResult.recording?.filePath != null) {
_playerSourceController.text = stopResult.recording!.filePath!;
_playerSourceType = _PlayerSourceType.lastRecording;
}
_isStreaming = false;
_isPaused = false;
_isWebSocketConnected = false;
_audioLevelDb = -96;
_audioPeak = 0;
_isSilent = true;
_isClipping = false;
_cameraStatus = 'Kamera kapalı';
_status = stopResult.recording?.isSuccessful == true
? 'Yayın durduruldu ve WebM kaydı hazır'
: '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> _exportLastRecording() async {
final recording = _lastRecording;
if (recording == null) return;
try {
final destination = await _controller.exportRecording(recording);
if (!mounted) return;
setState(() {
_status = destination == null
? 'Dosyalara kaydetme iptal edildi'
: 'Kayıt seçilen konuma kaydedildi';
});
} catch (error) {
if (!mounted) return;
setState(() => _errorMessage = 'Dışa aktarma hatası: $error');
}
}
Future<void> _shareLastRecording() async {
final recording = _lastRecording;
if (recording == null) return;
try {
await _controller.shareRecording(recording);
if (!mounted) return;
setState(() => _status = 'Paylaşım ekranı açıldı');
} catch (error) {
if (!mounted) return;
setState(() => _errorMessage = 'Paylaşım hatası: $error');
}
}
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');
}
}
}
Future<void> _openPlayerSource() async {
if (_isOpeningPlayer) return;
final value = _playerSourceController.text.trim();
LiveMediaSource source;
try {
source = switch (_playerSourceType) {
_PlayerSourceType.lastRecording => MediaFileSource(
_lastRecording?.filePath ?? value,
),
_PlayerSourceType.file => MediaFileSource(value),
_PlayerSourceType.url => MediaUrlSource.parse(value),
_PlayerSourceType.webSocket => WebSocketMediaSource(Uri.parse(value)),
};
if (value.isEmpty &&
!(_playerSourceType == _PlayerSourceType.lastRecording &&
_lastRecording?.filePath != null)) {
throw const FormatException(
'Önce bir kaynak adresi veya dosya yolu gir.',
);
}
setState(() {
_isOpeningPlayer = true;
_playerError = null;
});
await _playerController.open(source, autoplay: _playerAutoplay);
} catch (error) {
if (mounted) setState(() => _playerError = error.toString());
} finally {
if (mounted) setState(() => _isOpeningPlayer = false);
}
}
@override
void dispose() {
_eventSubscription?.cancel();
_packetSubscription?.cancel();
_statisticsSubscription?.cancel();
_socketMessageSubscription?.cancel();
_recordingFileNameController.dispose();
_playerSourceController.dispose();
_webSocketUrlController.dispose();
unawaited(_transport?.dispose());
_playerController.dispose();
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: [
_buildLearningGuideCard(context),
const SizedBox(height: 16),
_buildStatusCard(context),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
_buildErrorCard(context),
],
const SizedBox(height: 16),
_buildStreamSettingsCard(context),
const SizedBox(height: 16),
_buildRecordingCard(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(),
const SizedBox(height: 24),
_buildPlayerCard(context),
],
),
),
);
}
Widget _buildLearningGuideCard(BuildContext context) {
return Card(
color: Theme.of(context).colorScheme.primaryContainer,
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SDK öğrenme laboratuvarı',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 8),
const Text(
'Bu ekran yalnızca bir demo değildir. Aşağıdaki sırayla ilerleyerek '
'izinleri, native kodlamayı, WebSocket yayınını, WebM kaydını ve '
'player API’lerini gerçek cihazda deneyebilirsin.',
),
const SizedBox(height: 12),
const _LearningStep(
number: '1',
text: 'Ses, video veya ses + video modunu ve kaliteyi seç.',
),
const _LearningStep(
number: '2',
text:
'Yerel test için WebSocket’i kapalı bırak; sunucun hazırsa aç.',
),
const _LearningStep(
number: '3',
text:
'Native WebM kaydı aç ve isteğe bağlı dosya yolunu belirle.',
),
const _LearningStep(
number: '4',
text: 'Yayını başlat, ölçümleri izle ve güvenli biçimde durdur.',
),
const _LearningStep(
number: '5',
text: 'Oluşan dosyayı aşağıdaki player’da tek tuşla oynat.',
),
],
),
),
);
}
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: Platform.isIOS
? _resolution.height / _resolution.width
: _resolution.width / _resolution.height,
),
),
),
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
? _enableTransport
? 'WebM parçaları kaydediliyor ve WebSocket üzerinden gönderiliyor.'
: 'WebM parçaları yerel olarak üretiliyor ve kaydediliyor.'
: _enableTransport
? '${_mediaModeLabel(_mediaMode)} paketleri WebSocket üzerinden gönderiliyor.'
: '${_mediaModeLabel(_mediaMode)} paketleri yerel olarak üretiliyor.'
: '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: [
if (_enableTransport)
_StatusChip(
label: _isWebSocketConnected
? 'WebSocket bağlı'
: 'WebSocket kapalı',
icon: _isWebSocketConnected
? Icons.cloud_done_rounded
: Icons.cloud_off_rounded,
)
else
const _StatusChip(
label: 'Yerel test modu',
icon: Icons.phonelink_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.',
),
],
const Divider(height: 28),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('WebSocket sunucusuna gönder'),
subtitle: Text(
_enableTransport
? 'Kayıtla eşzamanlı olarak paketler sunucuya gönderilir.'
: 'Yerel kayıt testi: yayın sunucu bağlantısı olmadan çalışır.',
),
value: _enableTransport,
onChanged: _isStreaming
? null
: (value) => setState(() => _enableTransport = value),
),
TextField(
controller: _webSocketUrlController,
enabled: !_isStreaming && _enableTransport,
keyboardType: TextInputType.url,
minLines: 2,
maxLines: 3,
autocorrect: false,
enableSuggestions: false,
decoration: const InputDecoration(
labelText: 'WebSocket IP adresi veya URL',
hintText: '192.168.1.100:8080/live\nveya wss://sunucu.com/live',
helperText:
'Yerel IP’ye ws://, alan adına wss:// otomatik eklenir. '
'http(s) girilirse ws(s) biçimine çevrilir.',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 8),
const Text(
'WebSocket gönderimi ve kayıt birlikte açılabilir. Encode edilen '
'aynı Opus/VP8/VP9 paketleri ikinci encoder çalıştırılmadan hem '
'WebM dosyasına yazılır hem socket üzerinden gönderilir. Sunucu '
'binary frame’leri izleyicilere iletir veya depolar.',
),
],
),
),
);
}
Widget _buildRecordingCard(BuildContext context) {
final recording = _lastRecording;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Native WebM kayıt',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'Canlı yayın için zaten encode edilen Opus ve VP8/VP9 paketleri '
'ikinci encoder açılmadan WebM dosyasına yazılır. Ses, video ve '
'ses + video modlarının tamamında çalışır.',
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Yayın sırasında kaydet'),
subtitle: const Text(
'Kapalıysa dosya oluşmaz; yayın ve paket üretimi devam eder.',
),
value: _enableRecording,
onChanged: _isStreaming
? null
: (value) => setState(() => _enableRecording = value),
),
if (_enableRecording) ...[
const ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(Icons.folder_copy_outlined),
title: Text('Önce güvenli uygulama alanına kaydedilir'),
subtitle: Text(
'Kayıt tamamlanınca “Dosyalara kaydet” ile Documents, '
'Downloads, iCloud Drive veya seçtiğiniz başka bir konuma '
'aktarabilirsiniz.',
),
),
TextField(
controller: _recordingFileNameController,
enabled: !_isStreaming,
decoration: const InputDecoration(
labelText: 'Dosya adı (opsiyonel)',
hintText: 'ornek-yayin',
helperText:
'Yalnız adı yazın. Boşsa benzersiz ad üretilir; .webm otomatik eklenir.',
border: OutlineInputBorder(),
),
),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Aynı adlı dosyanın üzerine yaz'),
subtitle: const Text(
'Kapalıyken çakışmada sayısal ekle yeni ad oluşturulur.',
),
value: _overwriteRecording,
onChanged: _isStreaming
? null
: (value) =>
setState(() => _overwriteRecording = value ?? false),
),
],
if (_isStreaming && _statistics?.isRecording == true) ...[
const Divider(height: 28),
_InfoRow(
title: 'Canlı kayıt süresi',
value: _formatDuration(_statistics!.recordingDuration),
),
],
if (recording != null) ...[
const Divider(height: 28),
Text(
recording.isSuccessful
? 'Son kayıt başarıyla tamamlandı'
: 'Son kayıt tamamlanamadı',
style: Theme.of(context).textTheme.titleSmall,
),
_InfoRow(title: 'Durum', value: recording.status.name),
_InfoRow(
title: 'Dosya',
value: recording.filePath ?? 'Dosya oluşturulmadı',
),
_InfoRow(
title: 'Boyut',
value: _formatBytes(recording.fileSizeBytes),
),
_InfoRow(
title: 'Süre',
value: _formatDuration(recording.duration),
),
_InfoRow(
title: 'Medya',
value:
'${recording.hasAudio ? 'Opus ses' : 'Ses yok'} • '
'${recording.hasVideo ? '${recording.videoCodec?.name ?? 'VPX'} '
'${recording.width ?? '?'}×${recording.height ?? '?'} '
'${recording.framesPerSecond ?? '?'} FPS' : 'Video yok'}',
),
_InfoRow(
title: 'Paket/kare',
value:
'${recording.totalAudioPackets ?? 0} ses • '
'${recording.totalVideoFrames ?? 0} video • '
'${recording.keyFrameCount ?? 0} keyframe',
),
if (recording.errorMessage != null)
_InfoRow(title: 'Hata', value: recording.errorMessage!),
if (recording.isSuccessful && recording.filePath != null) ...[
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: _exportLastRecording,
icon: const Icon(Icons.save_alt),
label: const Text('Dosyalara kaydet'),
),
OutlinedButton.icon(
onPressed: _shareLastRecording,
icon: const Icon(Icons.share),
label: const Text('Paylaş'),
),
],
),
],
],
],
),
),
);
}
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',
),
_InfoRow(
title: 'Sunucu',
value: _webSocketUrlController.text.trim().isEmpty
? 'Girilmedi'
: _webSocketUrlController.text.trim(),
),
_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'),
),
),
],
);
}
Widget _buildPlayerCard(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Audio ve video player',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Text(
'Dosya ve HTTP(S) kaynaklarında player önce platformun native '
'container/codec desteğini kullanır. iOS’ta AVPlayer’ın açamadığı '
'WebM, libvpx/Opus hattına; Ogg Opus (.opus/.ogg/.oga) ise SDK '
'Ogg/Opus hattına yönlendirilir. WebSocket canlı yayın protokolü '
'sıralı WebM/SDK binary frame’idir.',
),
const SizedBox(height: 12),
DropdownButtonFormField<_PlayerSourceType>(
initialValue: _playerSourceType,
decoration: const InputDecoration(
labelText: 'Player kaynağı',
border: OutlineInputBorder(),
),
items: _PlayerSourceType.values
.map(
(type) =>
DropdownMenuItem(value: type, child: Text(type.label)),
)
.toList(),
onChanged: (value) {
if (value == null) return;
setState(() {
_playerSourceType = value;
if (value == _PlayerSourceType.lastRecording &&
_lastRecording?.filePath != null) {
_playerSourceController.text = _lastRecording!.filePath!;
}
});
},
),
const SizedBox(height: 12),
TextField(
controller: _playerSourceController,
decoration: InputDecoration(
labelText: switch (_playerSourceType) {
_PlayerSourceType.lastRecording => 'Son kayıt dosyası',
_PlayerSourceType.file => 'Yerel medya dosya yolu',
_PlayerSourceType.url => 'HTTP(S) medya URL',
_PlayerSourceType.webSocket => 'WebSocket URL',
},
hintText: switch (_playerSourceType) {
_PlayerSourceType.lastRecording =>
'Önce kayıtlı bir yayını durdur',
_PlayerSourceType.file => '/path/to/video.mp4',
_PlayerSourceType.url => 'https://example.com/video.m3u8',
_PlayerSourceType.webSocket => 'wss://example.com/watch/room',
},
border: const OutlineInputBorder(),
),
),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Kaynak açılınca otomatik oynat'),
value: _playerAutoplay,
onChanged: (value) => setState(() => _playerAutoplay = value),
),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _isOpeningPlayer ? null : _openPlayerSource,
icon: _isOpeningPlayer
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.play_circle_outline_rounded),
label: Text(
_isOpeningPlayer ? 'Kaynak açılıyor' : 'Kaynağı player’da aç',
),
),
),
if (_playerError != null) ...[
const SizedBox(height: 12),
Text(
_playerError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
const SizedBox(height: 16),
AnimatedBuilder(
animation: _playerController,
builder: (context, _) {
final state = _playerController.state;
final info = state.mediaInfo;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (info?.hasVideo == true)
LiveMediaVideoPlayer(
controller: _playerController,
width: double.infinity,
fit: BoxFit.contain,
fullscreenConfiguration: const FullscreenConfiguration(
orientation: FullscreenOrientation.automatic,
),
)
else
LiveMediaAudioPlayer(controller: _playerController),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_StatusChip(
label: 'Durum: ${state.status.name}',
icon: state.isPlaying
? Icons.play_arrow_rounded
: Icons.pause_rounded,
),
_StatusChip(
label:
'${_formatDuration(state.position)} / '
'${state.isLive ? 'CANLI' : _formatDuration(state.duration)}',
icon: Icons.schedule_rounded,
),
_StatusChip(
label: '${state.playbackSpeed}x',
icon: Icons.speed_rounded,
),
],
),
const SizedBox(height: 12),
DropdownButtonFormField<PlayerAudioOutput>(
initialValue: state.audioOutput,
decoration: const InputDecoration(
labelText: 'Player ses çıkışı',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(
value: PlayerAudioOutput.systemDefault,
child: Text('Sistem varsayılanı'),
),
DropdownMenuItem(
value: PlayerAudioOutput.speaker,
child: Text('Hoparlör'),
),
DropdownMenuItem(
value: PlayerAudioOutput.receiver,
child: Text('Ahize'),
),
],
onChanged: (value) async {
if (value == null) return;
try {
await _playerController.setAudioOutput(value);
} catch (error) {
if (!mounted) return;
setState(() {
_playerError = 'Ses çıkışı değiştirilemedi: $error';
});
}
},
),
if (info != null) ...[
const Divider(height: 28),
_InfoRow(
title: 'Kaynak',
value:
'${info.isLive ? 'Canlı' : 'Kayıtlı'} • '
'${info.isSeekable ? 'İleri/geri sarılabilir' : 'Seek kapalı'}',
),
_InfoRow(
title: 'Container / codec',
value:
'${info.container.toUpperCase()} • '
'${info.videoCodec ?? 'video yok'} • '
'${info.audioCodec ?? 'audio yok'}',
),
if (info.hasVideo)
_InfoRow(
title: 'Görüntü',
value:
'${info.width ?? '?'}×${info.height ?? '?'} • '
'oran ${info.aspectRatio?.toStringAsFixed(3) ?? '?'} • '
'dönüş ${info.rotationDegrees}°',
),
],
const SizedBox(height: 8),
const Text(
'Video kontrol çubuğu: play/pause, progress üzerinden seek, '
'geçen/toplam süre, mute, 0.25x–3x hız ve tam ekran. '
'width/height yerine scale verilirse tasarıma oransal '
'yerleşir. Canlı WebSocket kaynağında seek doğal olarak kapalıdır.',
),
],
);
},
),
],
),
),
);
}
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';
}
String _formatDuration(Duration duration) {
final total = duration.inSeconds.clamp(0, 359999);
final hours = total ~/ 3600;
final minutes = (total % 3600) ~/ 60;
final seconds = total % 60;
if (hours > 0) {
return '${hours.toString().padLeft(2, '0')}:'
'${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
return '${minutes.toString().padLeft(2, '0')}:'
'${seconds.toString().padLeft(2, '0')}';
}
}
enum _PlayerSourceType {
lastRecording('Son SDK kaydı'),
file('Yerel medya dosyası'),
url('HTTP(S) medya URL'),
webSocket('WebSocket canlı yayın');
const _PlayerSourceType(this.label);
final String label;
}
class _LearningStep extends StatelessWidget {
const _LearningStep({required this.number, required this.text});
final String number;
final String text;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CircleAvatar(
radius: 12,
child: Text(number, style: const TextStyle(fontSize: 12)),
),
const SizedBox(width: 10),
Expanded(child: Text(text)),
],
),
);
}
}
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),
),
),
],
),
);
}
}