234 lines
9.6 KiB
Dart
234 lines
9.6 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/gestures.dart' show kSecondaryButton;
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:jj_mkvmaker/app/app_controller.dart';
|
|
import 'package:jj_mkvmaker/core/playlist.dart';
|
|
import 'package:jj_mkvmaker/platform/windows/desktop_storage_service.dart';
|
|
import 'package:jj_mkvmaker/platform/windows/process_media_tool.dart';
|
|
import 'package:jj_mkvmaker/platform/windows/shell_integration.dart';
|
|
import 'package:jj_mkvmaker/platform/windows/single_instance.dart';
|
|
import 'package:jj_mkvmaker/services/media_player.dart';
|
|
import 'package:jj_mkvmaker/services/platform_services.dart';
|
|
import 'package:jj_mkvmaker/ui/player_page.dart';
|
|
import 'package:path/path.dart' as p;
|
|
|
|
class _FakePlayer implements MediaPlayer {
|
|
@override
|
|
final ValueNotifier<PlayerState> state = ValueNotifier(const PlayerState(
|
|
duration: Duration(minutes: 2),
|
|
audioTracks: [TrackInfo('1', 'kor'), TrackInfo('2', 'eng')],
|
|
subtitleTracks: [TrackInfo('3', '한국어')],
|
|
width: 1920,
|
|
height: 1080,
|
|
));
|
|
final calls = <String>[];
|
|
List<String> _files = [];
|
|
|
|
void _set({bool? playing, int? index, String? sub, bool clearSub = false, double? volume}) {
|
|
final s = state.value;
|
|
state.value = PlayerState(
|
|
position: s.position,
|
|
duration: s.duration,
|
|
playing: playing ?? s.playing,
|
|
index: index ?? s.index,
|
|
volume: volume ?? s.volume,
|
|
width: s.width,
|
|
height: s.height,
|
|
audioTracks: s.audioTracks,
|
|
subtitleTracks: s.subtitleTracks,
|
|
subtitleId: clearSub ? null : (sub ?? s.subtitleId),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Stream<String> get errors => const Stream.empty();
|
|
@override
|
|
List<String> get playlist => _files;
|
|
@override
|
|
Future<void> open(List<String> files, {int start = 0}) async {
|
|
_files = List.of(files);
|
|
calls.add('open ${files.length} $start');
|
|
_set(index: start, playing: true);
|
|
}
|
|
|
|
@override
|
|
Future<void> add(List<String> files) async => _files.addAll(files);
|
|
@override
|
|
Future<void> jump(int index) async => _set(index: index);
|
|
@override
|
|
Future<void> next() async => _set(index: (state.value.index + 1).clamp(0, _files.length - 1));
|
|
@override
|
|
Future<void> previous() async => _set(index: (state.value.index - 1).clamp(0, _files.length - 1));
|
|
@override
|
|
Future<void> playOrPause() async => _set(playing: !state.value.playing);
|
|
@override
|
|
Future<void> play() async => _set(playing: true);
|
|
@override
|
|
Future<void> stop() async => calls.add('stop');
|
|
@override
|
|
Future<void> seek(Duration position) async => calls.add('seek ${position.inSeconds}');
|
|
@override
|
|
Future<void> setVolume(double volume) async => _set(volume: volume);
|
|
@override
|
|
Future<void> setRate(double rate) async => calls.add('rate $rate');
|
|
@override
|
|
Future<void> setAudioTrack(TrackInfo t) async => calls.add('audio ${t.id}');
|
|
@override
|
|
Future<void> setSubtitleTrack(TrackInfo? t) async =>
|
|
t == null ? _set(clearSub: true) : _set(sub: t.id);
|
|
@override
|
|
Widget buildView() => const ColoredBox(color: Colors.black);
|
|
@override
|
|
Future<void> dispose() async => calls.add('dispose');
|
|
}
|
|
|
|
void main() {
|
|
group('재생 목록 규칙', () {
|
|
const dir = r'D:\v';
|
|
final files = [
|
|
for (final n in [
|
|
'file_010.mp4', 'file_002.mp4', 'file_1.mp4', 'Show.S01E02.mkv', 'Show.S01E10.mkv',
|
|
'Show.S02E01.mkv', 'other.avi', 'notes.txt', 'Show.S01E01.srt',
|
|
])
|
|
p.join(dir, n),
|
|
];
|
|
|
|
test('시리즈 키 · 자연 정렬', () {
|
|
expect(seriesKey('file_001.mp4'), seriesKey('file_0002.mp4'));
|
|
expect(seriesKey('Show.S01E02.mkv'), seriesKey('Show.S02E10.mkv'));
|
|
expect(seriesKey('other.avi') == seriesKey('file_1.mp4'), isFalse);
|
|
final names = ['a10', 'a2', 'a1', 'b1'];
|
|
names.sort(naturalCompare);
|
|
expect(names, ['a1', 'a2', 'a10', 'b1']);
|
|
});
|
|
|
|
test('하나 · 시리즈 · 폴더', () {
|
|
final f = p.join(dir, 'file_002.mp4');
|
|
final (one, k) = buildPlaylist(f, files, PlaylistMode.single);
|
|
expect([one, k], [[f], 0]);
|
|
final (series, i) = buildPlaylist(f, files, PlaylistMode.series);
|
|
expect(series.map(p.basename), ['file_1.mp4', 'file_002.mp4', 'file_010.mp4']);
|
|
expect(i, 1);
|
|
final (show, j) = buildPlaylist(p.join(dir, 'Show.S01E10.mkv'), files, PlaylistMode.series);
|
|
expect(show.map(p.basename), ['Show.S01E02.mkv', 'Show.S01E10.mkv', 'Show.S02E01.mkv']);
|
|
expect(j, 1);
|
|
final (all, _) = buildPlaylist(f, files, PlaylistMode.folder);
|
|
expect(all, hasLength(7)); // 자막·텍스트 제외
|
|
});
|
|
|
|
test('끌어다 놓기: 동영상만, 폴더 안쪽, jj_ 출력 폴더 제외', () async {
|
|
final tree = {
|
|
r'D:\drop': [r'D:\drop\a.mp4', r'D:\drop\b.txt', r'D:\drop\sub', r'D:\drop\jj_mkv'],
|
|
r'D:\drop\sub': [r'D:\drop\sub\c.mkv'],
|
|
r'D:\drop\jj_mkv': [r'D:\drop\jj_mkv\a.mkv'],
|
|
};
|
|
final v = await collectVideos([r'D:\drop', r'D:\x.avi', r'D:\y.jpg'],
|
|
isDirectory: (x) async => tree.containsKey(x), listDir: (x) async => List.of(tree[x]!));
|
|
expect(v, [r'D:\drop\a.mp4', r'D:\drop\sub\c.mkv', r'D:\x.avi']);
|
|
});
|
|
|
|
test('실행 인수', () {
|
|
final r = LaunchRequest.parse(['--play', r'D:\a.mp4', r'D:\b.mp4']);
|
|
expect([r.action, r.files], [LaunchAction.play, [r'D:\a.mp4', r'D:\b.mp4']]);
|
|
expect(LaunchRequest.parse([r'D:\a.mp4']).action, LaunchAction.add);
|
|
expect(LaunchRequest.parse(['--subtitle', 'x']).toArgs(), ['--subtitle', 'x']);
|
|
});
|
|
});
|
|
|
|
test('중복 실행 방지: 나중 실행은 넘기고, 짧은 시간의 요청은 하나로', () async {
|
|
final first = SingleInstance(port: 47999, gather: const Duration(milliseconds: 300));
|
|
expect(await first.claim(const LaunchRequest(LaunchAction.play, ['a.mp4'])), isTrue);
|
|
final got = <LaunchRequest>[];
|
|
first.listen(got.add);
|
|
|
|
// 탐색기가 파일마다 프로그램을 실행한 상황
|
|
for (final f in ['b.mp4', 'c.mp4']) {
|
|
expect(await SingleInstance(port: 47999).claim(LaunchRequest(LaunchAction.play, [f])), isFalse);
|
|
}
|
|
expect(await SingleInstance(port: 47999).claim(const LaunchRequest(LaunchAction.subtitle, ['d.mp4'])), isFalse);
|
|
await Future<void>.delayed(const Duration(milliseconds: 600));
|
|
await first.close();
|
|
|
|
expect(got, hasLength(2));
|
|
final play = got.firstWhere((r) => r.action == LaunchAction.play);
|
|
expect(play.files, ['a.mp4', 'b.mp4', 'c.mp4']);
|
|
expect(got.firstWhere((r) => r.action == LaunchAction.subtitle).files, ['d.mp4']);
|
|
});
|
|
|
|
test('탐색기 메뉴 등록 · 해제 (테스트 전용 확장자)', () async {
|
|
if (!Platform.isWindows) return markTestSkipped('Windows 전용');
|
|
final s = ShellIntegration(exePath: r'C:\JJ Test\jj_mkvmaker.exe', extensions: ['jjtestvid']);
|
|
await s.unregister();
|
|
expect(await s.isRegistered(), isFalse);
|
|
expect(await s.register(), isTrue);
|
|
expect(await s.isRegistered(), isTrue);
|
|
final q = await Process.run('reg', [
|
|
'query', r'HKCU\Software\Classes\SystemFileAssociations\.jjtestvid\shell\JJMKVMaker.Play\command', '/ve'
|
|
]);
|
|
expect('${q.stdout}', contains(r'"C:\JJ Test\jj_mkvmaker.exe" --play "%1"'));
|
|
final q2 = await Process.run('reg', [
|
|
'query', r'HKCU\Software\Classes\SystemFileAssociations\.jjtestvid\shell\JJMKVMaker.Subtitle\command', '/ve'
|
|
]);
|
|
expect('${q2.stdout}', contains('--subtitle "%1"'));
|
|
await s.unregister();
|
|
expect(await s.isRegistered(), isFalse);
|
|
});
|
|
|
|
testWidgets('플레이어 화면: 조작 · 단축키 · 오른쪽 메뉴 · 재생 목록', (tester) async {
|
|
tester.view.physicalSize = const Size(1400, 900);
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
|
|
final c = AppController(PlatformServices(mediaTool: ProcessMediaTool('x', 'y'), storage: DesktopStorageService()));
|
|
final pl = _FakePlayer();
|
|
await tester.pumpWidget(MaterialApp(
|
|
home: PlayerPage(c: c, player: pl, files: const [r'D:\v\ep1.mp4', r'D:\v\ep2.mp4'], start: 1),
|
|
));
|
|
await tester.pump();
|
|
expect(pl.calls.first, 'open 2 1');
|
|
expect(find.text('ep2.mp4 (2/2)'), findsOneWidget);
|
|
|
|
// Space: 일시정지
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.space);
|
|
await tester.pump();
|
|
expect(pl.state.value.playing, isFalse);
|
|
// → 10초 앞으로, P 이전 파일
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyP);
|
|
await tester.pump();
|
|
expect(pl.calls, contains('seek 10'));
|
|
expect(find.text('ep1.mp4 (1/2)'), findsOneWidget);
|
|
// S: 자막 켜기 → 첫 트랙
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyS);
|
|
await tester.pump();
|
|
expect(pl.state.value.subtitleId, '3');
|
|
|
|
// 재생 목록 (L) → 항목 누르면 이동
|
|
await tester.sendKeyEvent(LogicalKeyboardKey.keyL);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('재생 목록 2개'), findsOneWidget);
|
|
await tester.tap(find.text('ep2.mp4'));
|
|
await tester.pump();
|
|
expect(pl.state.value.index, 1);
|
|
|
|
// 오른쪽 클릭 메뉴
|
|
await tester.tap(find.byType(ColoredBox).first, buttons: kSecondaryButton);
|
|
await tester.pumpAndSettle();
|
|
expect(find.text('전체 화면'), findsOneWidget);
|
|
expect(find.text('팝업 보기'), findsOneWidget);
|
|
expect(find.text('자막 보기'), findsOneWidget);
|
|
expect(find.text('화면 크기'), findsOneWidget);
|
|
expect(find.text('음성 선택'), findsOneWidget); // 음성 트랙 2개
|
|
await tester.tap(find.text('자막 보기'));
|
|
await tester.pumpAndSettle();
|
|
expect(pl.state.value.subtitleId, isNull);
|
|
|
|
await tester.pumpWidget(const SizedBox());
|
|
expect(pl.calls.last, 'dispose');
|
|
});
|
|
}
|