Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions lib/utils/libass_web_bridge.dart
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';
12 changes: 12 additions & 0 deletions lib/utils/libass_web_bridge_stub.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) {}
}
92 changes: 92 additions & 0 deletions lib/utils/libass_web_bridge_web.dart
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');
}
}
64 changes: 64 additions & 0 deletions lib/utils/subtitle_manager.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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: 当前播放器内核不支持加载外部字幕');
Expand Down Expand Up @@ -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((_) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reapply saved subtitle delay when initializing libass

When an ASS/SSA track is activated here, the current subtitle delay preference is never pushed to the new libass instance. setSubtitleDelaySeconds only calls LibassWebBridge.setTimeOffset when 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 👍 / 👎.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid marking external subtitle active before libass is ready

This updates external_subtitle as active unconditionally even though LibassWebBridge.initWithUrl can return early (for example when JS helpers are missing or no <video> element is found). In that failure path, UI/state says subtitles are active and the path is persisted, but nothing is actually rendering, which can leave users stuck in a false-active state until they manually toggle subtitles.

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() =>
Expand Down
1 change: 1 addition & 0 deletions lib/utils/video_player_state.dart
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import 'package:nipaplay/services/web_remote_history_sync_service.dart';
import 'package:nipaplay/services/timeline_danmaku_service.dart'; // 导入时间轴弹幕服务
import 'package:nipaplay/services/danmaku_spoiler_filter_service.dart';
import 'package:nipaplay/services/web_remote_access_service.dart';
import 'package:nipaplay/utils/libass_web_bridge.dart';
import 'package:nipaplay/services/player_remote_control_bridge.dart';
import 'media_info_helper.dart';
import 'package:nipaplay/services/danmaku_cache_manager.dart';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1581,6 +1581,12 @@ extension VideoPlayerStatePreferences on VideoPlayerState {
_subtitleDelaySeconds = seconds;
final prefs = await SharedPreferences.getInstance();
await prefs.setDouble(_subtitleDelayKey, seconds);
// libass-wasm: timeOffset shifts subtitle timestamps relative to video position.
// Negate because a positive delay means we want subtitles to appear later,
// i.e., the sub timestamp "runs slower" → offset = -delay.
if (kIsWeb) {
LibassWebBridge.setTimeOffset(-seconds);
}
await applySubtitleStylePreference();
_notifyListeners();
}
Expand Down
2 changes: 2 additions & 0 deletions macos/Flutter/GeneratedPluginRegistrant.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import hotkey_manager_macos
import media_kit_libs_macos_video
import media_kit_video
import package_info_plus
import path_provider_foundation
import screen_brightness_macos
import screen_retriever_macos
import shared_preferences_foundation
Expand All @@ -41,6 +42,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
MediaKitLibsMacosVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitLibsMacosVideoPlugin"))
MediaKitVideoPlugin.register(with: registry.registrar(forPlugin: "MediaKitVideoPlugin"))
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
ScreenBrightnessMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenBrightnessMacosPlugin"))
ScreenRetrieverMacosPlugin.register(with: registry.registrar(forPlugin: "ScreenRetrieverMacosPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
Expand Down
Loading