-
Notifications
You must be signed in to change notification settings - Fork 97
feat(web): add libass-wasm support for ASS/SSA external subtitles #485
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export 'libass_web_bridge_stub.dart' | ||
| if (dart.library.js_interop) 'libass_web_bridge_web.dart'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| // Non-web stub — all methods are no-ops. | ||
|
|
||
| class LibassWebBridge { | ||
| static bool get available => false; | ||
| static bool get isActive => false; | ||
|
|
||
| static Future<void> initWithUrl(String subUrl) async {} | ||
| static Future<void> initWithContent(String assContent) async {} | ||
| static void dispose() {} | ||
| static void resize() {} | ||
| static void setTimeOffset(double seconds) {} | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| import 'dart:js_interop'; | ||
| import 'package:flutter/foundation.dart' show debugPrint; | ||
| import 'package:web/web.dart' as web; | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // JS side helpers (defined in web/index.html's inline <script>): | ||
| // window._nipaLibassCreate(videoElem, subUrl, subContent) | ||
| // window._nipaLibassDispose() | ||
| // window._nipaLibassResize() | ||
| // window._nipaLibassSetTimeOffset(offsetSeconds) | ||
| // window._nipaLibassIsActive() -> bool | ||
| // --------------------------------------------------------------------------- | ||
|
|
||
| @JS('_nipaLibassCreate') | ||
| external void _jsCreate(JSAny videoElem, JSString subUrl, JSString subContent); | ||
|
|
||
| @JS('_nipaLibassDispose') | ||
| external void _jsDispose(); | ||
|
|
||
| @JS('_nipaLibassResize') | ||
| external void _jsResize(); | ||
|
|
||
| @JS('_nipaLibassSetTimeOffset') | ||
| external void _jsSetTimeOffset(JSNumber offset); | ||
|
|
||
| @JS('_nipaLibassIsActive') | ||
| external JSBoolean _jsIsActive(); | ||
|
|
||
| // Check whether the JS helper was actually injected (guards against missing script tag). | ||
| @JS('_nipaLibassCreate') | ||
| external JSAny? get _jsCreateRef; | ||
|
|
||
| class LibassWebBridge { | ||
| static bool get available => _jsCreateRef != null; | ||
| static bool get isActive => _jsIsActive().toDart; | ||
|
|
||
| static Future<void> initWithUrl(String subUrl) async { | ||
| if (!available) { | ||
| debugPrint('[LibassWebBridge] JS helpers not loaded — skipping initWithUrl'); | ||
| return; | ||
| } | ||
| final video = _findVideoElement(); | ||
| if (video == null) { | ||
| debugPrint('[LibassWebBridge] No <video> element found in DOM'); | ||
| return; | ||
| } | ||
| _jsCreate(video, subUrl.toJS, ''.toJS); | ||
| debugPrint('[LibassWebBridge] initWithUrl: $subUrl'); | ||
| } | ||
|
|
||
| static Future<void> initWithContent(String assContent) async { | ||
| if (!available) { | ||
| debugPrint('[LibassWebBridge] JS helpers not loaded — skipping initWithContent'); | ||
| return; | ||
| } | ||
| final video = _findVideoElement(); | ||
| if (video == null) { | ||
| debugPrint('[LibassWebBridge] No <video> element found in DOM'); | ||
| return; | ||
| } | ||
| _jsCreate(video, ''.toJS, assContent.toJS); | ||
| debugPrint('[LibassWebBridge] initWithContent (${assContent.length} chars)'); | ||
| } | ||
|
|
||
| static void dispose() { | ||
| if (!available) return; | ||
| _jsDispose(); | ||
| debugPrint('[LibassWebBridge] disposed'); | ||
| } | ||
|
|
||
| static void resize() { | ||
| if (!available || !isActive) return; | ||
| _jsResize(); | ||
| } | ||
|
|
||
| static void setTimeOffset(double seconds) { | ||
| if (!available || !isActive) return; | ||
| _jsSetTimeOffset(seconds.toJS); | ||
| } | ||
|
|
||
| // Returns the first <video> element that is currently playing (or the first one found). | ||
| static web.Element? _findVideoElement() { | ||
| // Prefer a playing video element. | ||
| final all = web.document.querySelectorAll('video'); | ||
| for (var i = 0; i < all.length; i++) { | ||
| final el = all.item(i) as web.HTMLVideoElement?; | ||
| if (el != null && !el.paused) return el; | ||
| } | ||
| // Fall back to the first video element. | ||
| return web.document.querySelector('video'); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ import '../../player_abstraction/player_abstraction.dart'; | |
| import 'package:nipaplay/services/remote_subtitle_service.dart'; | ||
| import 'package:nipaplay/utils/subtitle_file_utils.dart'; | ||
| import 'package:nipaplay/utils/subtitle_language_utils.dart'; | ||
| import 'package:nipaplay/utils/libass_web_bridge.dart'; | ||
|
|
||
| /// 字幕管理器类,负责处理与字幕相关的所有功能 | ||
| class SubtitleManager extends ChangeNotifier { | ||
|
|
@@ -304,6 +305,11 @@ class SubtitleManager extends ChangeNotifier { | |
|
|
||
| // 清空外部字幕状态,同时通知播放器关闭外挂轨道 | ||
| void _clearExternalSubtitleState({bool resetManualFlag = true}) { | ||
| // 若 libass-wasm 正在渲染,先释放它 | ||
| if (kIsWeb) { | ||
| LibassWebBridge.dispose(); | ||
| } | ||
|
|
||
| try { | ||
| if (_player.supportsExternalSubtitles) { | ||
| _player.setMedia("", MediaType.subtitle); | ||
|
|
@@ -350,6 +356,27 @@ class SubtitleManager extends ChangeNotifier { | |
| final loadToken = ++_subtitleLoadToken; | ||
| final previousSubtitleTrackSignatures = | ||
| _isMdkKernel() ? _snapshotCurrentSubtitleTrackSignatures() : null; | ||
|
|
||
| // Web + ASS/SSA: 通过 libass-wasm (SubtitlesOctopus) 渲染,绕过内核字幕管道 | ||
| if (kIsWeb) { | ||
| if (path.isEmpty) { | ||
| LibassWebBridge.dispose(); | ||
| _clearExternalSubtitleState(); | ||
| onSubtitleTrackChanged(); | ||
| notifyListeners(); | ||
| return; | ||
| } | ||
| final ext = p.extension(path).toLowerCase(); | ||
| if (ext == '.ass' || ext == '.ssa') { | ||
| _activateLibassSubtitle(path, isManualSetting: isManualSetting); | ||
| return; | ||
| } | ||
| // 非 ASS/SSA 格式在 web 上暂不支持 | ||
| debugPrint('SubtitleManager: Web 平台暂仅支持 ASS/SSA 外挂字幕'); | ||
| onUserNotification?.call('Web 播放器仅支持 .ass/.ssa 外挂字幕'); | ||
| return; | ||
| } | ||
|
|
||
| // NEW: Check if player supports external subtitles | ||
| if (!_player.supportsExternalSubtitles && path.isNotEmpty) { | ||
| debugPrint('SubtitleManager: 当前播放器内核不支持加载外部字幕'); | ||
|
|
@@ -428,6 +455,43 @@ class SubtitleManager extends ChangeNotifier { | |
| } | ||
| } | ||
|
|
||
| // Web ASS/SSA 字幕:通过 libass-wasm (SubtitlesOctopus) 渲染 | ||
| void _activateLibassSubtitle(String path, {bool isManualSetting = false}) { | ||
| // 先释放旧实例(initWithUrl 内部也会释放,此处为了状态一致性先清理) | ||
| LibassWebBridge.dispose(); | ||
|
|
||
| unawaited(LibassWebBridge.initWithUrl(path).then((_) { | ||
| debugPrint('SubtitleManager: libass 字幕已激活: $path'); | ||
| }).catchError((e) { | ||
| debugPrint('SubtitleManager: libass 字幕激活失败: $e'); | ||
| })); | ||
|
|
||
| _currentExternalSubtitlePath = path; | ||
| updateSubtitleTrackInfo('external_subtitle', { | ||
| 'path': path, | ||
| 'title': p.basename(path), | ||
| 'isActive': true, | ||
| 'isManualSet': isManualSetting, | ||
|
Comment on lines
+470
to
+474
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This updates Useful? React with 👍 / 👎. |
||
| 'isLibass': true, | ||
| }); | ||
|
|
||
| if (isManualSetting && _currentVideoPath != null) { | ||
| unawaited(saveVideoSubtitleMapping(_currentVideoPath!, path)); | ||
| } | ||
| if (_currentVideoPath != null && _currentVideoPath!.isNotEmpty) { | ||
| unawaited( | ||
| _persistExternalSubtitleSelection( | ||
| videoPath: _currentVideoPath!, | ||
| subtitlePath: path, | ||
| isActive: true, | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| onSubtitleTrackChanged(); | ||
| notifyListeners(); | ||
| } | ||
|
|
||
| bool _isMdkKernel() => _player.getPlayerKernelName() == 'MDK'; | ||
| bool _isMediaKitKernel() => _player.getPlayerKernelName() == 'Media Kit'; | ||
| bool _shouldFixExternalSubtitleEncoding() => | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When an ASS/SSA track is activated here, the current subtitle delay preference is never pushed to the new libass instance.
setSubtitleDelaySecondsonly callsLibassWebBridge.setTimeOffsetwhen the user changes the slider, and that bridge method is a no-op while libass is inactive, so any delay configured before loading the subtitle is lost and playback starts with zero offset. This causes persistent subtitle-sync settings to be ignored until the user changes delay again.Useful? React with 👍 / 👎.