import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:video_player_win/video_player_win.dart'; import '../../durum/proje_durumu.dart'; import '../../modeller/segment.dart'; import '../../servisler/ffmpeg/ffmpeg_servis.dart'; import '../../servisler/segment_kesici.dart'; const int _kFps = 24; const int _kFrameMs = 1000 ~/ _kFps; // ~41ms class OnizlemeOynatici extends ConsumerStatefulWidget { const OnizlemeOynatici({super.key}); @override ConsumerState createState() => _OnizlemeOynaticiState(); } class _OnizlemeOynaticiState extends ConsumerState { WinVideoPlayerController? _ctrl; String? _aktifYol; Timer? _ilerlemeTimer; Duration _pozisyon = Duration.zero; Duration _toplamSure = Duration.zero; bool _oynuyor = false; double _ses = 1.0; bool _yukleniyor = false; bool _loop = false; bool _altyaziGoster = true; final FocusNode _focusNode = FocusNode(); Future _yukle(String? yol) async { if (_aktifYol == yol) return; if (_yukleniyor) return; _yukleniyor = true; try { _aktifYol = yol; _ilerlemeTimer?.cancel(); _ilerlemeTimer = null; final eski = _ctrl; _ctrl = null; if (eski != null) { try { await eski.pause(); } catch (_) {} await eski.dispose(); } if (yol == null) { if (mounted) setState(() {}); return; } final c = WinVideoPlayerController.file(File(yol)); await c.initialize(); if (!mounted) { await c.dispose(); return; } if (_aktifYol != yol) { await c.dispose(); return; } setState(() { _ctrl = c; _toplamSure = c.value.duration; _pozisyon = Duration.zero; }); c.setVolume(_ses); c.play(); _oynuyor = true; _ilerlemeTimer = Timer.periodic(const Duration(milliseconds: 250), (_) async { if (_ctrl == null || !mounted) return; final pos = _ctrl!.value.position; if (_loop && _toplamSure > Duration.zero && pos >= _toplamSure - const Duration(milliseconds: 200)) { try { await _ctrl!.seekTo(Duration.zero); await _ctrl!.play(); } catch (_) {} } if (mounted) { setState(() { _pozisyon = pos; _oynuyor = _ctrl!.value.isPlaying; }); } }); } finally { _yukleniyor = false; } } String? _hesaplaYol() { final proje = ref.read(projeProvider); final secId = ref.read(secilenSegmentIdProvider); if (proje == null || secId == null) return null; final seg = proje.segmentler.firstWhere( (s) => s.id == secId, orElse: () => Segment(id: '', sira: -1, basla: 0, bit: 0), ); if (seg.id.isEmpty) return null; final yol = seg.videoYolu; if (yol == null || !File(yol).existsSync()) return null; return yol; } @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _yukle(_hesaplaYol()); }); } Future _oynatDurakla() async { if (_ctrl == null) return; if (_oynuyor) { await _ctrl!.pause(); } else { await _ctrl!.play(); } setState(() => _oynuyor = !_oynuyor); } Future _sonaSar(Duration sure) async { if (_ctrl == null) return; final hedef = sure < Duration.zero ? Duration.zero : (sure > _toplamSure ? _toplamSure : sure); await _ctrl!.seekTo(hedef); setState(() => _pozisyon = hedef); } Future _kareSar(int yon) async { if (_ctrl == null) return; if (_oynuyor) { await _ctrl!.pause(); _oynuyor = false; } final hedef = _pozisyon + Duration(milliseconds: _kFrameMs * yon); await _sonaSar(hedef); } /// Profesyonel kesim akışı: /// 1. Mevcut oynatma pozisyonunda video kesilir (re-encode) /// 2. Bu segmentin bit'i o saniyeye çekilir, yeni son kare oluşur /// 3. Kazanılan delta süre, BİR SONRAKİ segmente eklenir (basla geri çekilir) /// 4. Sonraki segment 20s'i aşarsa otomatik yeni segment(ler) bölünür /// 5. Şarkı timeline'ı / toplam proje süresi KORUNUR Future _bukareyeKadarKes() async { final proje = ref.read(projeProvider); final secId = ref.read(secilenSegmentIdProvider); if (proje == null || secId == null) { _bildir('Proje veya segment seçilmedi'); return; } final seg = proje.segmentler.firstWhere( (s) => s.id == secId, orElse: () => Segment(id: '', sira: -1, basla: 0, bit: 0), ); if (seg.id.isEmpty || seg.videoYolu == null) { _bildir('Bu segment için aktif video yok'); return; } final kesimSn = _pozisyon.inMilliseconds / 1000.0; // Önizleme final on = SegmentKesici.onizle( proje: proje, segmentId: seg.id, kesimSaniye: kesimSn, ); if (!on.gecerli) { _bildir('Kesim yapılamaz: ${on.hata}'); return; } if (!mounted) return; final onay = await showDialog( context: context, useRootNavigator: true, builder: (ctx) { final theme = Theme.of(ctx); return AlertDialog( icon: const Icon(Icons.content_cut, color: Colors.deepOrange, size: 32), title: const Text('Bu segmenti burada bitir?'), content: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _OzetSatir( ikon: Icons.movie_filter_outlined, baslik: 'S${seg.sira + 1} kesilecek', deger: '${on.eskiSure.toStringAsFixed(2)}s → ${on.yeniSure.toStringAsFixed(2)}s', vurgu: Colors.deepOrange, ), _OzetSatir( ikon: Icons.swap_horiz, baslik: 'Kazanılan süre', deger: '${on.delta.toStringAsFixed(2)}s', ), _OzetSatir( ikon: Icons.east, baslik: 'S${on.sonrakiSira + 1}\'e eklenecek (basla geri çekilir)', deger: '→ ${on.sonrakiYeniSure.toStringAsFixed(2)}s', vurgu: theme.colorScheme.primary, ), if (on.eklenecekYeniSayisi > 0) _OzetSatir( ikon: Icons.add_box_outlined, baslik: '20s limiti aşıldı — yeni segment eklenecek', deger: '${on.eklenecekYeniSayisi} adet (max 20s/her biri)', vurgu: Colors.amber.shade700, ), const Divider(height: 20), Row( children: [ Icon(Icons.lock_clock, size: 14, color: theme.colorScheme.outline), const SizedBox(width: 6), Text( 'Toplam proje süresi DEĞİŞMEZ — şarkı senkronu korunur', style: theme.textTheme.bodySmall?.copyWith( color: theme.colorScheme.outline, fontStyle: FontStyle.italic, ), ), ], ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('İptal'), ), FilledButton.icon( icon: const Icon(Icons.check, size: 16), label: const Text('Evet, kes ve yeniden düzenle'), style: FilledButton.styleFrom(backgroundColor: Colors.deepOrange), onPressed: () => Navigator.of(ctx).pop(true), ), ], ); }, ); if (onay != true) return; // İlerleme bildirimi if (!mounted) return; final messenger = ScaffoldMessenger.of(context); messenger.clearSnackBars(); messenger.showSnackBar( const SnackBar( content: Row( children: [ SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), SizedBox(width: 12), Expanded( child: Text( 'Video kesiliyor (yeniden kodlanıyor)...')), ], ), duration: Duration(seconds: 30), ), ); // Oynatmayı durdur — dosya kilidi olmasın if (_oynuyor) { try { await _ctrl?.pause(); } catch (_) {} _oynuyor = false; } final sonuc = await SegmentKesici.kesVeYenidenDuzenle( proje: proje, segmentId: seg.id, kesimSaniye: kesimSn, ); if (!mounted) return; messenger.clearSnackBars(); if (!sonuc.basarili) { messenger.showSnackBar( SnackBar( content: Text('❌ Kesim başarısız: ${sonuc.hata}'), backgroundColor: Colors.red, ), ); return; } // Projeyi güncelle final notifier = ref.read(projeProvider.notifier); notifier.ayarla(sonuc.yeniProje!); await notifier.hemenKaydet(); // Aktif videoyu yeniden yükle (yeni kesilmiş dosya) await _yukle(_hesaplaYol()); messenger.showSnackBar( SnackBar( content: Text( '✂️ S${sonuc.kesilenSegmentSira + 1} kesildi · ' 'S${sonuc.sonrakiSegmentSira + 1} → ${sonuc.sonrakiYeniSure.toStringAsFixed(2)}s' '${sonuc.eklenecekYeniSayisi > 0 ? " · ${sonuc.eklenecekYeniSayisi} yeni segment" : ""} · ' 'toplam süre korundu', ), duration: const Duration(seconds: 5), backgroundColor: Colors.green.shade700, ), ); } Future _snapshotAl() async { if (_aktifYol == null) { _bildir('Aktif video yok'); return; } final ms = _pozisyon.inMilliseconds; final dosya = File(_aktifYol!); final dizin = dosya.parent.path; final ad = dosya.uri.pathSegments.last.split('.').first; final cikti = '$dizin${Platform.pathSeparator}${ad}_frame_$ms.png'; final ssSn = (ms / 1000.0).toStringAsFixed(3); try { final r = await Process.run(FfmpegServis.ffmpegYolu, [ '-y', '-ss', ssSn, '-i', _aktifYol!, '-vframes', '1', cikti, ]); if (r.exitCode == 0) { _bildir('Snapshot kaydedildi: $cikti'); } else { _bildir('Snapshot başarısız: ${r.stderr.toString().split('\n').last}'); } } catch (e) { _bildir('Snapshot hatası: $e'); } } void _bildir(String msj) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(msj), duration: const Duration(seconds: 3)), ); } void _klavye(KeyEvent ev) { if (ev is! KeyDownEvent) return; // TextField/EditableText fokustaysa hiçbir şey yapma — yazı yazılıyor. final primary = FocusManager.instance.primaryFocus; final fctx = primary?.context; if (fctx != null) { final w = fctx.widget; if (w is EditableText) return; if (fctx.findAncestorWidgetOfExactType() != null) return; } final k = ev.logicalKey; if (k == LogicalKeyboardKey.space || k == LogicalKeyboardKey.keyK) { _oynatDurakla(); } else if (k == LogicalKeyboardKey.keyJ) { _kareSar(-1); } else if (k == LogicalKeyboardKey.keyL) { _kareSar(1); } else if (k == LogicalKeyboardKey.keyF) { _snapshotAl(); } } String _format(Duration d) { String iki(int n) => n.toString().padLeft(2, '0'); final dk = d.inMinutes.remainder(60); final sn = d.inSeconds.remainder(60); return '${iki(dk)}:${iki(sn)}'; } String _altyaziMetni(Segment seg) { final ham = (seg.notlar.isNotEmpty ? seg.notlar : (seg.prompt ?? '')).trim(); if (ham.isEmpty) return ''; if (ham.length <= 80) return ham; return '${ham.substring(0, 80)}...'; } @override void dispose() { _ilerlemeTimer?.cancel(); _ctrl?.dispose(); _focusNode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { ref.listen(secilenSegmentIdProvider, (eski, yeni) { if (eski != yeni) _yukle(_hesaplaYol()); }); ref.listen(projeProvider, (eski, yeni) { final yeniYol = _hesaplaYol(); if (yeniYol != _aktifYol) _yukle(yeniYol); }); final proje = ref.watch(projeProvider); final secId = ref.watch(secilenSegmentIdProvider); Segment? seg; if (proje != null && secId != null) { seg = proje.segmentler.firstWhere( (s) => s.id == secId, orElse: () => Segment(id: '', sira: -1, basla: 0, bit: 0), ); if (seg.id.isEmpty) seg = null; } final theme = Theme.of(context); final altyazi = seg != null ? _altyaziMetni(seg) : ''; return KeyboardListener( focusNode: _focusNode, autofocus: false, onKeyEvent: _klavye, child: Container( decoration: BoxDecoration( color: Colors.black, borderRadius: BorderRadius.circular(8), border: Border.all(color: theme.dividerColor), ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: _ctrl != null && _ctrl!.value.isInitialized ? Column( children: [ Expanded( child: Stack( children: [ Positioned.fill( child: GestureDetector( onTap: () { _focusNode.requestFocus(); _oynatDurakla(); }, child: Center( child: FittedBox( fit: BoxFit.contain, child: SizedBox( width: _ctrl!.value.size.width <= 0 ? 16 : _ctrl!.value.size.width, height: _ctrl!.value.size.height <= 0 ? 9 : _ctrl!.value.size.height, child: WinVideoPlayer(_ctrl!), ), ), ), ), ), Positioned( top: 8, right: 8, child: _SesMetre(ses: _ses, oynuyor: _oynuyor), ), if (_altyaziGoster && altyazi.isNotEmpty) Positioned( bottom: 8, left: 16, right: 16, child: Center( child: Container( padding: const EdgeInsets.symmetric( horizontal: 8, vertical: 4), decoration: BoxDecoration( color: Colors.black54, borderRadius: BorderRadius.circular(4), ), child: Text( altyazi, textAlign: TextAlign.center, style: const TextStyle( color: Colors.white, fontSize: 13, ), ), ), ), ), ], ), ), _Kontroller( oynuyor: _oynuyor, pozisyon: _pozisyon, toplam: _toplamSure, ses: _ses, loop: _loop, altyaziGoster: _altyaziGoster, onOynatDurakla: _oynatDurakla, onSar: _sonaSar, onSesi: (v) { setState(() => _ses = v); _ctrl?.setVolume(v); }, onLoopToggle: () => setState(() => _loop = !_loop), onAltyaziToggle: () => setState(() => _altyaziGoster = !_altyaziGoster), onSnapshot: _snapshotAl, onKes: _bukareyeKadarKes, formatla: _format, ), ], ) : Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( seg == null ? Icons.movie_outlined : Icons.play_circle_outline, size: 48, color: theme.colorScheme.outline, ), const SizedBox(height: 8), Text( seg == null ? 'Segment seçilmedi' : 'Henüz üretilmedi', style: TextStyle(color: theme.colorScheme.outline), ), if (seg != null) ...[ const SizedBox(height: 4), Text( 'S${seg.sira + 1} · ${(seg.bit - seg.basla).toStringAsFixed(1)}s', style: theme.textTheme.labelSmall, ), ], ], ), ), ), ), ); } } class _SesMetre extends StatelessWidget { final double ses; final bool oynuyor; const _SesMetre({required this.ses, required this.oynuyor}); @override Widget build(BuildContext context) { final aktif = oynuyor && ses > 0; final dolu = aktif ? (ses * 4).ceil().clamp(1, 4) : 0; const yukseklikler = [4.0, 8.0, 12.0, 16.0]; return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.black.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(3), ), child: Row( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: List.generate(4, (i) { final aktifBar = i < dolu; return Padding( padding: EdgeInsets.only(left: i == 0 ? 0 : 2), child: AnimatedContainer( duration: const Duration(milliseconds: 150), width: 3, height: yukseklikler[i], color: aktifBar ? Colors.greenAccent : Colors.white.withValues(alpha: 0.2), ), ); }), ), ); } } class _Kontroller extends StatefulWidget { final bool oynuyor; final Duration pozisyon; final Duration toplam; final double ses; final bool loop; final bool altyaziGoster; final VoidCallback onOynatDurakla; final void Function(Duration) onSar; final ValueChanged onSesi; final VoidCallback onLoopToggle; final VoidCallback onAltyaziToggle; final VoidCallback onSnapshot; final VoidCallback onKes; final String Function(Duration) formatla; const _Kontroller({ required this.oynuyor, required this.pozisyon, required this.toplam, required this.ses, required this.loop, required this.altyaziGoster, required this.onOynatDurakla, required this.onSar, required this.onSesi, required this.onLoopToggle, required this.onAltyaziToggle, required this.onSnapshot, required this.onKes, required this.formatla, }); @override State<_Kontroller> createState() => _KontrollerState(); } class _KontrollerState extends State<_Kontroller> { double? _surukleDeger; @override Widget build(BuildContext context) { final maxMs = widget.toplam.inMilliseconds == 0 ? 1.0 : widget.toplam.inMilliseconds.toDouble(); final ms = (_surukleDeger ?? widget.pozisyon.inMilliseconds.toDouble()) .clamp(0.0, maxMs); final gosterilenPozisyon = _surukleDeger != null ? Duration(milliseconds: _surukleDeger!.round()) : widget.pozisyon; return Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), color: Colors.black.withValues(alpha: 0.85), child: Row( children: [ IconButton( icon: Icon( widget.oynuyor ? Icons.pause : Icons.play_arrow, color: Colors.white, ), onPressed: widget.onOynatDurakla, iconSize: 22, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 32, minHeight: 32), ), IconButton( icon: const Icon(Icons.replay_10, color: Colors.white), tooltip: '10s geri', iconSize: 18, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), onPressed: () => widget.onSar(widget.pozisyon - const Duration(seconds: 10)), ), IconButton( icon: const Icon(Icons.forward_10, color: Colors.white), tooltip: '10s ileri', iconSize: 18, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), onPressed: () => widget.onSar(widget.pozisyon + const Duration(seconds: 10)), ), IconButton( icon: Icon( Icons.subtitles_outlined, color: widget.altyaziGoster ? Colors.lightBlueAccent : Colors.white, ), tooltip: widget.altyaziGoster ? 'Altyazıyı gizle' : 'Altyazıyı göster', iconSize: 18, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), onPressed: widget.onAltyaziToggle, ), const SizedBox(width: 6), Text( widget.formatla(gosterilenPozisyon), style: const TextStyle(color: Colors.white, fontSize: 11), ), Expanded( child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 3, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6), overlayShape: const RoundSliderOverlayShape(overlayRadius: 12), ), child: Slider( value: ms, min: 0, max: maxMs, onChanged: (v) => setState(() => _surukleDeger = v), onChangeEnd: (v) { widget.onSar(Duration(milliseconds: v.round())); setState(() => _surukleDeger = null); }, ), ), ), Text( widget.formatla(widget.toplam), style: const TextStyle(color: Colors.white, fontSize: 11), ), const SizedBox(width: 6), IconButton( icon: Icon( Icons.loop, color: widget.loop ? Colors.lightBlueAccent : Colors.white, ), tooltip: widget.loop ? 'Döngü açık' : 'Döngü kapalı', iconSize: 18, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), onPressed: widget.onLoopToggle, ), Icon( widget.ses == 0 ? Icons.volume_off : (widget.ses < 0.5 ? Icons.volume_down : Icons.volume_up), color: Colors.white, size: 16, ), SizedBox( width: 70, child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 2, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5), ), child: Slider( value: widget.ses, min: 0, max: 1, onChanged: widget.onSesi, ), ), ), IconButton( icon: const Icon(Icons.camera_alt, color: Colors.white), tooltip: 'Snapshot al (F)', iconSize: 18, padding: EdgeInsets.zero, constraints: const BoxConstraints(minWidth: 28, minHeight: 28), onPressed: widget.onSnapshot, ), // Profesyonel kesim butonu — bu segmenti şu anki pozisyonda bitir, // sonraki segmentlere kazanılan süreyi dağıt (timeline korunur). Tooltip( richMessage: const TextSpan( children: [ TextSpan( text: 'Bu segmenti burada bitir\n', style: TextStyle(fontWeight: FontWeight.bold), ), TextSpan( text: 'Video bu saniyede kesilir, sonraki segmentlere kazanılan ' 'süre dağıtılır. Şarkı senkronu korunur, gerekirse yeni ' 'segment oluşur (max 20s/her biri).', style: TextStyle(fontSize: 11), ), ], ), child: Container( margin: const EdgeInsets.only(left: 4), decoration: BoxDecoration( color: Colors.deepOrange.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(4), border: Border.all( color: Colors.deepOrange.withValues(alpha: 0.5), width: 0.8, ), ), child: IconButton( icon: const Icon(Icons.content_cut, color: Colors.deepOrange), iconSize: 16, padding: const EdgeInsets.symmetric(horizontal: 6), constraints: const BoxConstraints(minWidth: 28, minHeight: 28), onPressed: widget.onKes, ), ), ), ], ), ); } } /// Onay dialog'u için tek satır özet (ikon + başlık + değer). class _OzetSatir extends StatelessWidget { final IconData ikon; final String baslik; final String deger; final Color? vurgu; const _OzetSatir({ required this.ikon, required this.baslik, required this.deger, this.vurgu, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(ikon, size: 16, color: vurgu ?? theme.colorScheme.outline), const SizedBox(width: 8), Expanded( child: Text(baslik, style: theme.textTheme.bodySmall), ), const SizedBox(width: 8), Text( deger, style: theme.textTheme.bodyMedium?.copyWith( fontWeight: FontWeight.w600, color: vurgu, fontFeatures: const [FontFeature.tabularFigures()], ), ), ], ), ); } }