Initial commit

This commit is contained in:
admin1
2026-07-14 11:11:36 +08:00
commit 656499cf94
604 changed files with 119518 additions and 0 deletions
@@ -0,0 +1,107 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/playback.dart';
import 'package:smplayer/features/player/session/initial_subtitle_resolver.dart';
import 'package:smplayer/providers/subtitle_settings_provider.dart';
const subtitles = <EmbySubtitleTrack>[
EmbySubtitleTrack(index: 2, language: 'chi', title: '简体中文'),
EmbySubtitleTrack(index: 3, language: 'eng', title: 'English'),
];
RememberedSubtitleTrack rememberedSubtitle({
int streamIndex = 2,
String? language,
String? title,
}) {
return RememberedSubtitleTrack(
streamIndex: streamIndex,
language: language,
title: title,
updatedAt: DateTime(2026),
);
}
InitialSubtitleSelection resolveSelection({
List<EmbySubtitleTrack> availableSubtitles = subtitles,
int? preferredSubtitleStreamIndex,
int? defaultSubtitleStreamIndex,
RememberedSubtitleTrack? remembered,
}) {
return resolveInitialSubtitleSelection(
subtitles: availableSubtitles,
preferredSubtitleStreamIndex: preferredSubtitleStreamIndex,
defaultSubtitleStreamIndex: defaultSubtitleStreamIndex,
rememberedSubtitle: remembered,
);
}
void main() {
test('显式 route 轨优先于记忆与服务器默认轨', () {
final selection = resolveSelection(
preferredSubtitleStreamIndex: 3,
defaultSubtitleStreamIndex: 2,
remembered: rememberedSubtitle(streamIndex: 2),
);
expect(selection.type, InitialSubtitleSelectionType.track);
expect(selection.track?.index, 3);
});
test('route -1 且没有记忆时禁用字幕', () {
final selection = resolveSelection(
preferredSubtitleStreamIndex: -1,
defaultSubtitleStreamIndex: 2,
);
expect(selection.type, InitialSubtitleSelectionType.disabled);
expect(selection.track, isNull);
});
test('禁用字幕记忆优先于 route -1 与服务器默认轨', () {
final selection = resolveSelection(
preferredSubtitleStreamIndex: -1,
defaultSubtitleStreamIndex: 2,
remembered: RememberedSubtitleTrack(
streamIndex: -1,
updatedAt: DateTime(2026),
),
);
expect(selection.type, InitialSubtitleSelectionType.disabled);
});
test('跨集记忆按语言与标题匹配可用轨', () {
final selection = resolveSelection(
defaultSubtitleStreamIndex: 3,
remembered: rememberedSubtitle(
streamIndex: 99,
language: 'CHI',
title: '简体中文',
),
);
expect(selection.type, InitialSubtitleSelectionType.track);
expect(selection.track?.index, 2);
});
test('记忆未命中时回落服务器默认轨', () {
final selection = resolveSelection(
defaultSubtitleStreamIndex: 3,
remembered: rememberedSubtitle(
streamIndex: 99,
language: 'jpn',
title: '日本語',
),
);
expect(selection.type, InitialSubtitleSelectionType.track);
expect(selection.track?.index, 3);
});
test('没有 route、记忆或服务器默认轨时保持引擎状态', () {
final selection = resolveSelection(availableSubtitles: const []);
expect(selection.type, InitialSubtitleSelectionType.unchanged);
expect(selection.track, isNull);
});
}
@@ -0,0 +1,28 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/infra/player_engine/media3_player_engine.dart';
void main() {
group('isMedia3NetworkError', () {
test('命中所有 ERROR_CODE_IO_* 网络类错误', () {
expect(
isMedia3NetworkError('ERROR_CODE_IO_NETWORK_CONNECTION_FAILED'),
isTrue,
);
expect(isMedia3NetworkError('ERROR_CODE_IO_BAD_HTTP_STATUS'), isTrue);
expect(isMedia3NetworkError('ERROR_CODE_IO_FILE_NOT_FOUND'), isTrue);
});
test('反直觉边界:TIMEOUT 不带 IO_ 前缀 → 不命中(走宽限等待)', () {
expect(isMedia3NetworkError('ERROR_CODE_TIMEOUT'), isFalse);
});
test('解码/解析类错误不命中', () {
expect(isMedia3NetworkError('ERROR_CODE_DECODING_FAILED'), isFalse);
expect(
isMedia3NetworkError('ERROR_CODE_PARSING_CONTAINER_MALFORMED'),
isFalse,
);
expect(isMedia3NetworkError('playback_error'), isFalse);
});
});
}
@@ -0,0 +1,155 @@
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/cancellation.dart';
import 'package:smplayer/core/infra/player_engine/player_engine.dart';
import 'package:smplayer/features/player/session/player_engine_holder.dart';
import 'package:smplayer/features/player/session/player_engine_resolution.dart';
void main() {
group('PlayerEngineHolder', () {
test('同 kind 复用:第二次 acquire 不再调 create', () async {
final holder = PlayerEngineHolder();
var createCalls = 0;
_FakeEngine factory(String tag) {
createCalls++;
return _FakeEngine(tag);
}
final first = await holder.acquire(
PlayerEngineKind.media3,
() => factory('a'),
);
final second = await holder.acquire(
PlayerEngineKind.media3,
() => factory('b'),
);
expect(createCalls, 1);
expect(identical(first, second), isTrue);
expect((second as _FakeEngine).tag, 'a');
});
test('异 kind dispose+create:旧引擎先 dispose 再新建', () async {
final holder = PlayerEngineHolder();
final disposeOrder = <String>[];
final acquireOrder = <String>[];
final fvp = _FakeEngine(
'fvp',
onDispose: () async {
await Future<void>.delayed(const Duration(milliseconds: 10));
disposeOrder.add('fvp');
},
);
await holder.acquire(PlayerEngineKind.fvp, () => fvp);
acquireOrder.add('fvp');
final media3 = _FakeEngine('media3');
final second = await holder.acquire(PlayerEngineKind.media3, () {
acquireOrder.add('media3-create');
return media3;
});
expect(disposeOrder, ['fvp']);
expect(acquireOrder, ['fvp', 'media3-create']);
expect(second, media3);
expect(fvp.disposed, isTrue);
expect(media3.disposed, isFalse);
});
});
}
class _FakeEngine implements PlayerEngine {
final String tag;
final Future<void> Function()? onDispose;
bool disposed = false;
_FakeEngine(this.tag, {this.onDispose});
@override
Future<void> dispose() async {
if (disposed) return;
if (onDispose != null) await onDispose!();
disposed = true;
}
@override
String get displayName => tag;
@override
bool get usesPlatformSurface => false;
@override
Future<int?> totalRxBytes() async => null;
@override
bool isNetworkStreamError(String error) => false;
@override
void configureHardwareDecoding(String mode) {}
@override
Future<void> open(
String url, {
Duration start = Duration.zero,
bool play = true,
Map<String, String>? headers,
CancellationToken? cancel,
}) async {}
@override
Future<void> play() async {}
@override
Future<void> pause() async {}
@override
Future<void> stop() async {}
@override
Future<void> seek(Duration position) async {}
@override
Future<void> setRate(double rate) async {}
@override
Future<void> setVolume(double volume) async {}
@override
Future<void> toggleMute() async {}
@override
Future<void> setAudioTrack(AudioTrack track) async {}
@override
Future<void> setSubtitleTrack(SubtitleTrack track) async {}
@override
void setNativeSubtitleRendering(bool enabled) {}
@override
void setSubtitleStyle({
required double fontSize,
required double bgOpacity,
required int position,
}) {}
@override
Widget buildVideoView(BuildContext context, ValueListenable<BoxFit> fit) =>
const SizedBox.shrink();
Future<String> getProperty(String name) async => '';
@override
PlayerState get state => PlayerState();
@override
PlayerStreams get stream => PlayerStreams();
@override
ValueListenable<int?> get textureId => ValueNotifier<int?>(null);
@override
ValueListenable<int> get textureVersion => ValueNotifier<int>(0);
@override
ValueNotifier<ui.Size?> get videoSize => ValueNotifier<ui.Size?>(null);
@override
ValueListenable<PlaybackStats?> get debugStats =>
ValueNotifier<PlaybackStats?>(null);
}
@@ -0,0 +1,236 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/features/player/session/player_engine_resolution.dart';
import 'package:smplayer/providers/player_engine_override_provider.dart';
void main() {
group('resolvePlayerEngineKind - media3 override', () {
test('Android + 非 DV/ProRes → media3', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.media3,
isDolbyVision: false,
videoCodec: 'h264',
isAndroid: true,
),
PlayerEngineKind.media3,
);
});
test('桌面 → mpv(不论 DV / codec', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.media3,
isDolbyVision: false,
videoCodec: 'h264',
isAndroid: false,
),
PlayerEngineKind.mpv,
);
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.media3,
isDolbyVision: true,
videoCodec: 'prores',
isAndroid: false,
),
PlayerEngineKind.mpv,
);
});
test('Android + DV → media3(用户显式选 media3 时直通 DV)', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.media3,
isDolbyVision: true,
videoCodec: 'hevc',
isAndroid: true,
),
PlayerEngineKind.media3,
);
});
test('Android + ProRes → fvpMediaCodec 不解 ProRes', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.media3,
isDolbyVision: false,
videoCodec: 'prores',
isAndroid: true,
),
PlayerEngineKind.fvp,
);
});
});
group('resolvePlayerEngineKind - auto behavior', () {
test('auto + Android + 非 DV/ProRes → media3', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.auto,
isDolbyVision: false,
videoCodec: 'h264',
isAndroid: true,
),
PlayerEngineKind.media3,
);
});
test('auto + Android + DV → media3', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.auto,
isDolbyVision: true,
videoCodec: 'hevc',
isAndroid: true,
),
PlayerEngineKind.media3,
);
});
test('auto + Android + ProRes → fvp', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.auto,
isDolbyVision: false,
videoCodec: 'prores',
isAndroid: true,
),
PlayerEngineKind.fvp,
);
});
test('auto + 桌面 → mpv', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.auto,
isDolbyVision: false,
videoCodec: 'h264',
isAndroid: false,
),
PlayerEngineKind.mpv,
);
});
test('auto + 桌面 DV → fvp', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.auto,
isDolbyVision: true,
videoCodec: 'hevc',
isAndroid: false,
),
PlayerEngineKind.fvp,
);
});
test('fvp override → 总是 fvp', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.fvp,
isDolbyVision: false,
videoCodec: 'h264',
isAndroid: false,
),
PlayerEngineKind.fvp,
);
});
test('mpv override → 总是 mpv(即便 DV', () {
expect(
resolvePlayerEngineKind(
PlayerEngineOverride.mpv,
isDolbyVision: true,
videoCodec: 'hevc',
isAndroid: true,
),
PlayerEngineKind.mpv,
);
});
});
group('playerEngineKindFromDisplayName', () {
test('media3 → PlayerEngineKind.media3', () {
expect(
playerEngineKindFromDisplayName('media3'),
PlayerEngineKind.media3,
);
});
test('fvp / mpv 仍可反查', () {
expect(playerEngineKindFromDisplayName('fvp'), PlayerEngineKind.fvp);
expect(playerEngineKindFromDisplayName('mpv'), PlayerEngineKind.mpv);
});
test('未知 displayName → null', () {
expect(playerEngineKindFromDisplayName('unknown'), isNull);
});
});
group('nextAutoFallbackEngine', () {
test('auto + media3 失败(非 DV)→ mpv', () {
expect(
nextAutoFallbackEngine(
override: PlayerEngineOverride.auto,
failedEngineKind: PlayerEngineKind.media3,
isDolbyVision: false,
),
PlayerEngineKind.mpv,
);
});
test('auto + media3 失败(DV)→ 跳过 mpv 直达 fvp', () {
expect(
nextAutoFallbackEngine(
override: PlayerEngineOverride.auto,
failedEngineKind: PlayerEngineKind.media3,
isDolbyVision: true,
),
PlayerEngineKind.fvp,
);
});
test('auto + mpv 失败 → fvp', () {
expect(
nextAutoFallbackEngine(
override: PlayerEngineOverride.auto,
failedEngineKind: PlayerEngineKind.mpv,
isDolbyVision: false,
),
PlayerEngineKind.fvp,
);
});
test('auto + fvp 失败 → 无回退', () {
expect(
nextAutoFallbackEngine(
override: PlayerEngineOverride.auto,
failedEngineKind: PlayerEngineKind.fvp,
isDolbyVision: true,
),
isNull,
);
});
test('非 auto 不自动回退', () {
expect(
nextAutoFallbackEngine(
override: PlayerEngineOverride.media3,
failedEngineKind: PlayerEngineKind.media3,
isDolbyVision: true,
),
isNull,
);
});
test('未知失败引擎不自动回退', () {
expect(
nextAutoFallbackEngine(
override: PlayerEngineOverride.auto,
failedEngineKind: null,
isDolbyVision: false,
),
isNull,
);
});
});
}
@@ -0,0 +1,47 @@
import 'package:fake_async/fake_async.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/features/player/session/relink_scheduler.dart';
void main() {
group('RelinkScheduler', () {
test('schedule cancels pending sustain timer (attempts must accumulate)',
() {
fakeAsync((async) {
final s = RelinkScheduler();
var gaveUp = false;
void failOnce() {
s.schedule(onRelink: (_) {}, onGiveUp: () => gaveUp = true);
async.flushTimers(flushPeriodicTimers: false);
s.armSustain(sustain: const Duration(seconds: 10));
async.elapse(const Duration(seconds: 5));
}
failOnce();
failOnce();
failOnce();
expect(gaveUp, isFalse);
s.schedule(onRelink: (_) {}, onGiveUp: () => gaveUp = true);
expect(gaveUp, isTrue);
s.dispose();
});
});
test('sustained playback resets attempts', () {
fakeAsync((async) {
final s = RelinkScheduler();
s.schedule(onRelink: (_) {}, onGiveUp: () {});
async.flushTimers(flushPeriodicTimers: false);
expect(s.attempts, 1);
s.armSustain(sustain: const Duration(seconds: 10));
async.elapse(const Duration(seconds: 11));
expect(s.attempts, 0);
s.dispose();
});
});
});
}
@@ -0,0 +1,129 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/features/player/session/stall_detector.dart';
void main() {
group('StallDetector', () {
test('有字节统计时下载继续便不判定停滞', () {
final detector = StallDetector(threshold: const Duration(seconds: 10));
expect(
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 5),
bytesDownloaded: 100,
isActivelyPlaying: true,
now: Duration.zero,
),
isFalse,
);
expect(
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 5),
bytesDownloaded: 200,
isActivelyPlaying: true,
now: const Duration(seconds: 12),
),
isFalse,
);
});
test('无字节统计时缓冲终点继续前进便不判定停滞', () {
final detector = StallDetector(threshold: const Duration(seconds: 10));
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 5),
bytesDownloaded: null,
isActivelyPlaying: true,
now: Duration.zero,
);
expect(
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 8),
bytesDownloaded: null,
isActivelyPlaying: true,
now: const Duration(seconds: 12),
),
isFalse,
);
});
test('累计字节首次变为可用时视为传输进展', () {
final detector = StallDetector(threshold: const Duration(seconds: 10));
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 5),
bytesDownloaded: null,
isActivelyPlaying: true,
now: Duration.zero,
);
expect(
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 5),
bytesDownloaded: 100,
isActivelyPlaying: true,
now: const Duration(seconds: 12),
),
isFalse,
);
});
test('无字节统计且仍有明显缓冲余量时不重连', () {
final detector = StallDetector(threshold: const Duration(seconds: 10));
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 20),
bytesDownloaded: null,
isActivelyPlaying: true,
now: Duration.zero,
);
expect(
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 20),
bytesDownloaded: null,
isActivelyPlaying: true,
now: const Duration(seconds: 12),
),
isFalse,
);
});
test('所有可用进展信号停止到阈值后只触发一次', () {
final detector = StallDetector(threshold: const Duration(seconds: 10));
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 5),
bytesDownloaded: null,
isActivelyPlaying: true,
now: Duration.zero,
);
expect(
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 5),
bytesDownloaded: null,
isActivelyPlaying: true,
now: const Duration(seconds: 10),
),
isTrue,
);
expect(
detector.update(
position: const Duration(seconds: 5),
bufferedPosition: const Duration(seconds: 5),
bytesDownloaded: null,
isActivelyPlaying: true,
now: const Duration(seconds: 20),
),
isFalse,
);
});
});
}
@@ -0,0 +1,55 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:smplayer/core/contracts/playback.dart';
import 'package:smplayer/features/player/session/initial_subtitle_resolver.dart';
import 'package:smplayer/providers/subtitle_settings_provider.dart';
RememberedSubtitleTrack _remembered({
int streamIndex = 3,
String? language,
String? title,
}) {
return RememberedSubtitleTrack(
streamIndex: streamIndex,
language: language,
title: title,
updatedAt: DateTime(2026),
);
}
void main() {
const embedded = [
EmbySubtitleTrack(index: 2, language: 'chi', title: '简体中文'),
EmbySubtitleTrack(index: 3, language: 'chi', title: '繁體中文'),
EmbySubtitleTrack(index: 4, language: 'eng', title: 'English'),
EmbySubtitleTrack(index: 5, title: 'Signs & Songs'),
];
test('lang + title 精确命中(大小写不敏感)', () {
final r = _remembered(language: 'CHI', title: '繁體中文');
expect(matchRememberedSubtitle(r, embedded), 3);
});
test('language 为 null 时按 title 命中', () {
final r = _remembered(streamIndex: 99, title: 'signs & songs');
expect(matchRememberedSubtitle(r, embedded), 5);
});
test('title 不匹配时回落仅 lang 宽松匹配', () {
final r = _remembered(language: 'chi', title: '不存在的标题');
expect(matchRememberedSubtitle(r, embedded), 2);
});
test('lang/title 全空时按 streamIndex 直配', () {
final r = _remembered(streamIndex: 4);
expect(matchRememberedSubtitle(r, embedded), 4);
});
test('全部 miss 返回 null', () {
final r = _remembered(streamIndex: 99, language: 'jpn', title: '日本語');
expect(matchRememberedSubtitle(r, embedded), isNull);
});
test('空轨道列表返回 null', () {
expect(matchRememberedSubtitle(_remembered(), const []), isNull);
});
}