플레이어: 건너뛴 뒤 다음 편으로 넘어가던 것 · 지운 WebDAV 서버를 보던 창

- mpv keep-open=always: 파일 끝에서 mpv 가 스스로 다음 파일로 넘어가지 않게 하고 앱이 정한다.
  건너뛴 직후 (3초 안, 끝 5초 전보다 앞) 의 "끝" 은 잘못 본 것 → 그 곳으로 키프레임 기준 다시 건너뜀.
  진짜 끝이면 다음 파일로 넘어가 계속 재생.
- 탐색기: 설정에서 지운 WebDAV 서버를 보던 창은 이 기기의 첫 저장 장치로.
- integration_test/seek_repro_test.dart (JJ_SEEK_FILE 일 때): 여러 곳 건너뛰기 · 진짜 끝에서 다음 파일.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
묶음3
RyuJongJin 2026-10-08 13:42:47 +09:00
parent b3707e2ce5
commit 44ae09a31f
3 changed files with 136 additions and 13 deletions

View File

@ -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<void> wait(int ms) async {
for (var i = 0; i < ms ~/ 100; i++) {
await tester.runAsync(() => Future<void>.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);
}

View File

@ -31,6 +31,11 @@ class MediaKitFullPlayer implements MediaPlayer {
final _errors = StreamController<String>.broadcast();
List<String> _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<void> open(List<String> 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<void> seek(Duration position) =>
_p.seek(position.isNegative ? Duration.zero : position);
Future<void> 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<void> _keepOpen() async {
final native = _p.platform;
if (native is mk.NativePlayer) await native.setProperty('keep-open', 'always');
}
/// 파일 끝: 건너뛴 직후 (3초 안) 이고 건너뛴 곳이 끝 근처가 아니면 잘못 본 끝 → 그 곳으로 다시 (키프레임 기준).
/// 다시 해도 끝이면 그 자리에 둔다. 진짜 끝이면 다음 파일로.
Future<void> _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<void> setVolume(double volume) => _p.setVolume(volume.clamp(0, 100).toDouble());
@override

View File

@ -238,6 +238,22 @@ class _ExplorerPageState extends State<ExplorerPage> {
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<ExplorerPage> {
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()),
]),
),
]);
},
),
),
),