live_media_stream_sdk 0.1.0 copy "live_media_stream_sdk: ^0.1.0" to clipboard
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),
            ),
          ),
        ],
      ),
    );
  }
}
0
likes
0
points
682
downloads

Publisher

unverified uploader

Weekly Downloads

Self-hosted Flutter live streaming with native Opus and VP8/VP9 encoding, WebM output, advanced camera/audio controls, and any binary WebSocket server.

Repository (GitHub)
View/report issues

Topics

#streaming #audio #video #webm #websocket

License

unknown (license)

Dependencies

flutter, plugin_platform_interface, web_socket_channel

More

Packages that depend on live_media_stream_sdk

Packages that implement live_media_stream_sdk