플레이어: 건너뛴 뒤 다음 편으로 넘어가던 것 · 지운 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
parent
b3707e2ce5
commit
44ae09a31f
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,7 +1538,9 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
child: Scaffold(
|
||||
body: ListenableBuilder(
|
||||
listenable: c,
|
||||
builder: (context, _) => Column(children: [
|
||||
builder: (context, _) {
|
||||
_dropRemovedServers();
|
||||
return Column(children: [
|
||||
_topBar(),
|
||||
Expanded(
|
||||
child: Stack(children: [
|
||||
|
|
@ -1531,7 +1549,8 @@ class _ExplorerPageState extends State<ExplorerPage> {
|
|||
Positioned(left: 8, right: 8, bottom: 0, child: _transferPanel()),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Reference in New Issue