diff --git a/integration_test/seek_repro_test.dart b/integration_test/seek_repro_test.dart new file mode 100644 index 0000000..54d9225 --- /dev/null +++ b/integration_test/seek_repro_test.dart @@ -0,0 +1,59 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:jj_mkvmaker/platform/common/media_kit_full_player.dart'; +import 'package:jj_mkvmaker/platform/common/media_kit_player.dart'; + +/// 재생 바로 건너뛰면 다음 파일로 넘어가는지 (JJ_SEEK_FILE = 시험할 동영상) +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + final file = Platform.environment['JJ_SEEK_FILE']; + + testWidgets('건너뛰기: 다음 파일로 넘어가지 않는다', (tester) async { + MediaKitPreviewPlayer.ensureInitialized(); + final pl = MediaKitFullPlayer(); + await tester.pumpWidget(MaterialApp(home: Scaffold(body: pl.buildView()))); + Future wait(int ms) async { + for (var i = 0; i < ms ~/ 100; i++) { + await tester.runAsync(() => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(); + } + } + + await tester.runAsync(() => pl.open([file!, file])); + for (var i = 0; i < 50 && pl.state.value.duration == Duration.zero; i++) { + await wait(100); + } + final total = pl.state.value.duration; + debugPrint('SEEK total $total'); + var jumped = 0; + for (final f in [0.1, 0.5, 0.9, 0.3, 0.7, 0.95, 0.2, 0.6, 0.8, 0.4, 0.99, 0.05]) { + // 끌기처럼 여러 번 빠르게 + for (final d in [-0.02, -0.01, 0.0]) { + await tester.runAsync(() => pl.seek(total * (f + d).clamp(0, 1))); + } + await wait(1500); + final s = pl.state.value; + debugPrint('SEEK ${(f * 100).round()}% → index ${s.index} pos ${s.position} dur ${s.duration} done ${s.completed}'); + if (s.index != 0) { + jumped++; + await tester.runAsync(() => pl.jump(0)); + await wait(1500); + } + } + debugPrint('SEEK jumped $jumped'); + expect(jumped, 0); + // 진짜 끝: 끝 2초 전으로 건너뛰면 끝난 뒤 다음 파일로 + await tester.runAsync(() => pl.play()); + await tester.runAsync(() => pl.seek(total - const Duration(seconds: 2))); + for (var i = 0; i < 80 && pl.state.value.index != 1; i++) { + await wait(100); + } + debugPrint('SEEK end → index ${pl.state.value.index} pos ${pl.state.value.position}'); + expect(pl.state.value.index, 1); + await wait(1500); + expect(pl.state.value.playing, isTrue); + }, skip: file == null); +} diff --git a/lib/platform/common/media_kit_full_player.dart b/lib/platform/common/media_kit_full_player.dart index 90eb492..93238be 100644 --- a/lib/platform/common/media_kit_full_player.dart +++ b/lib/platform/common/media_kit_full_player.dart @@ -31,6 +31,11 @@ class MediaKitFullPlayer implements MediaPlayer { final _errors = StreamController.broadcast(); List _files = []; + /// 마지막으로 건너뛴 때 · 위치 (건너뛴 직후의 "끝" 이 진짜인지 가리기) + DateTime _seekAt = DateTime(0); + Duration _seekTo = Duration.zero; + bool _seekRetried = false; + /// 외부 자막 파일로 켠 트랙 (mpv 목록에는 경로가 id 로 나타남) TrackInfo? _externalSub; @@ -43,7 +48,10 @@ class MediaKitFullPlayer implements MediaPlayer { ..add(s.position.listen((_) => _emit())) ..add(s.duration.listen((_) => _emit())) ..add(s.playing.listen((_) => _emit())) - ..add(s.completed.listen((_) => _emit())) + ..add(s.completed.listen((done) { + _emit(); + if (done) unawaited(_onEnd()); + })) ..add(s.volume.listen((_) => _emit())) ..add(s.rate.listen((_) => _emit())) ..add(s.width.listen((_) => _emit())) @@ -105,6 +113,7 @@ class MediaKitFullPlayer implements MediaPlayer { Future open(List files, {int start = 0}) async { _files = List.of(files); _externalSub = null; + await _keepOpen(); await _p.open(mk.Playlist([for (final f in files) await _media(f)], index: start)); } @@ -148,8 +157,44 @@ class MediaKitFullPlayer implements MediaPlayer { } @override - Future seek(Duration position) => - _p.seek(position.isNegative ? Duration.zero : position); + Future seek(Duration position) { + _seekAt = DateTime.now(); + _seekTo = position.isNegative ? Duration.zero : position; + _seekRetried = false; + return _p.seek(_seekTo); + } + + /// mpv 가 파일 끝에서 스스로 다음 파일로 넘어가지 않게 한다 (넘기기는 [_onEnd] 가 정한다). + /// 일부 파일 (AVI 에서 옮긴 MKV 등) 은 Android 에서 건너뛰기 직후 mpv 가 끝에 닿은 것으로 잘못 보고 + /// 다음 편으로 넘어가 버렸다. + Future _keepOpen() async { + final native = _p.platform; + if (native is mk.NativePlayer) await native.setProperty('keep-open', 'always'); + } + + /// 파일 끝: 건너뛴 직후 (3초 안) 이고 건너뛴 곳이 끝 근처가 아니면 잘못 본 끝 → 그 곳으로 다시 (키프레임 기준). + /// 다시 해도 끝이면 그 자리에 둔다. 진짜 끝이면 다음 파일로. + Future _onEnd() async { + final st = _p.state; + final dur = st.duration; + final recent = DateTime.now().difference(_seekAt) < const Duration(seconds: 3); + if (recent && dur > Duration.zero && _seekTo < dur - const Duration(seconds: 5)) { + if (_seekRetried) return; + _seekRetried = true; + final native = _p.platform; + if (native is mk.NativePlayer) { + await native.command(['seek', (_seekTo.inMilliseconds / 1000).toStringAsFixed(3), 'absolute+keyframes']); + } else { + await _p.seek(_seekTo); + } + await _p.play(); + return; + } + if (st.playlist.index < st.playlist.medias.length - 1) { + await _p.next(); + await _p.play(); + } + } @override Future setVolume(double volume) => _p.setVolume(volume.clamp(0, 100).toDouble()); @override diff --git a/lib/ui/explorer_page.dart b/lib/ui/explorer_page.dart index 9fe6240..88955a6 100644 --- a/lib/ui/explorer_page.dart +++ b/lib/ui/explorer_page.dart @@ -238,6 +238,22 @@ class _ExplorerPageState extends State { return [('/', '/')]; } + /// 설정에서 지운 WebDAV 서버를 보던 창은 이 기기의 첫 저장 장치로 + void _dropRemovedServers() { + if (!_ready) return; + for (final pane in _panes) { + if (isDav(pane.root) && DavRegistry.server(DavPath.parse(pane.root).server) == null) { + pane.marked.clear(); + pane.cache.removeWhere((k, _) => isDav(k)); + pane.expanded.removeWhere(isDav); + pane.root = _local.first.$1; // 다시 그릴 때 또 하지 않게 + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _goTo(pane, _local.first.$1); + }); + } + } + } + /// 다시 열 수 있는 폴더: 로컬은 있으면, WebDAV 는 그 서버가 아직 설정에 있으면 (열 때 확인) bool _reachable(String path) => isDav(path) ? DavRegistry.server(DavPath.parse(path).server) != null @@ -1522,16 +1538,19 @@ class _ExplorerPageState extends State { child: Scaffold( body: ListenableBuilder( listenable: c, - builder: (context, _) => Column(children: [ - _topBar(), - Expanded( - child: Stack(children: [ - Positioned.fill(child: _ready ? _body() : const Center(child: CircularProgressIndicator())), - // 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다 - Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()), - ]), - ), - ]), + builder: (context, _) { + _dropRemovedServers(); + return Column(children: [ + _topBar(), + Expanded( + child: Stack(children: [ + Positioned.fill(child: _ready ? _body() : const Center(child: CircularProgressIndicator())), + // 복사 · 이동 진행: 아래에서 올라오고 끝나면 내려간다 + Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()), + ]), + ), + ]); + }, ), ), ),