part of 'danmaku_panel_body.dart'; extension _DanmakuPanelBodyView on _DanmakuPanelBodyState { Widget? _buildEmptyState() { final state = widget.sessionState; if (state is DanmakuSessionIdle) { if (!widget.danmakuSourceConfigured) { return _emptyStatePage( icon: Icons.subtitles, title: '弹幕未配置', description: '请先在设置中配置弹幕数据源', actions: [ if (widget.onGoToSettings != null) _emptyStateCta('前往设置', widget.onGoToSettings!), ], ); } if (widget.danmakuGlobalEnabled) { return _emptyStatePage( icon: Icons.subtitles, title: '正在搜索弹幕…', showSpinner: true, ); } return _emptyStatePage( icon: Icons.subtitles, title: '弹幕已关闭', description: '开启后将自动匹配弹幕', actions: [ if (widget.onToggleDanmakuEnabled != null) _emptyStateCta('开启弹幕', widget.onToggleDanmakuEnabled!), ], ); } if (state is DanmakuSessionMatching || (state is DanmakuSessionMatched && state.commentStatus == DanmakuCommentStatus.loading)) { return _emptyStatePage( icon: Icons.subtitles, title: '正在搜索弹幕…', showSpinner: true, ); } if (state is DanmakuSessionEmpty) { return _emptyStatePage( icon: Icons.search_off, title: '未找到匹配弹幕', description: '可尝试手动搜索匹配', actions: [_emptyStateCta('搜索弹幕', widget.onOpenSearch)], ); } if (state is DanmakuSessionFailed) { return _emptyStatePage( icon: Icons.error_outline, title: '加载失败', description: '弹幕匹配请求出错', actions: [ if (widget.onRetry != null) _emptyStateCta('重试', widget.onRetry!), _emptyStateCta('搜索弹幕', widget.onOpenSearch), ], ); } return null; } Widget _emptyStatePage({ required IconData icon, required String title, String? description, List actions = const [], bool showSpinner = false, }) { return SingleChildScrollView( child: Container( padding: const EdgeInsets.symmetric(vertical: 28, horizontal: 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ if (showSpinner) Stack( alignment: Alignment.center, children: [ Icon(icon, color: const Color(0x61FFFFFF), size: 40), const AppLoadingRing(size: 52, color: Color(0x99FFFFFF)), ], ) else Icon(icon, color: const Color(0x61FFFFFF), size: 40), const SizedBox(height: 12), Text( title, style: const TextStyle( color: Colors.white70, fontSize: 14, fontWeight: FontWeight.w500, ), ), if (description != null) ...[ const SizedBox(height: 4), Text( description, style: const TextStyle(color: Colors.white38, fontSize: 12), textAlign: TextAlign.center, ), ], if (actions.isNotEmpty) ...[ const SizedBox(height: 16), Row( mainAxisAlignment: MainAxisAlignment.center, children: [ for (var i = 0; i < actions.length; i++) ...[ if (i > 0) const SizedBox(width: 8), actions[i], ], ], ), ], ], ), ), ); } Widget _emptyStateCta(String label, VoidCallback onTap) { return TextButton( onPressed: onTap, style: TextButton.styleFrom( foregroundColor: Colors.white, backgroundColor: Colors.white12, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), ), child: Text(label, style: const TextStyle(fontSize: 12)), ); } Widget _buildMainMenu() { final hasMatch = widget.selectedMatchIndex >= 0 && widget.selectedMatchIndex < widget.matches.length; final matchTitle = hasMatch ? widget.matches[widget.selectedMatchIndex].animeTitle : null; return SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _sectionHeader('弹幕数据'), _menuItem( icon: Icons.search, label: '搜索弹幕', hasCheck: hasMatch, subtitle: matchTitle, onTap: widget.onOpenSearch, ), const FDivider( style: .delta( color: Color(0x1AFFFFFF), padding: .value(EdgeInsetsDirectional.only(start: 16)), ), ), _sectionHeader('操作'), _menuItem( icon: Icons.tune, label: '显示设置', onTap: () => _navigateTo(_DanmakuView.settings), ), _menuItem( icon: Icons.filter_alt_outlined, label: '过滤关键词', badge: widget.blockedKeywords.isNotEmpty ? '${widget.blockedKeywords.length}' : null, onTap: () => _navigateTo(_DanmakuView.filter), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: PanelToggleRow( label: '高能进度条', value: s.heatmapEnabled, onChanged: (v) => _emit(s.copyWith(heatmapEnabled: v)), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: PanelToggleRow( label: '弹幕', value: s.enabled, onChanged: (v) => _emit(s.copyWith(enabled: v)), ), ), const SizedBox(height: 4), ], ), ); } Widget _sectionHeader(String title) { return Padding( padding: const EdgeInsets.fromLTRB(16, 10, 16, 4), child: Text( title, style: const TextStyle( color: Colors.white38, fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: 0.4, ), ), ); } Widget _menuItem({ required IconData icon, required String label, String? subtitle, String? badge, bool hasCheck = false, bool showArrow = true, required VoidCallback onTap, }) { return InkWell( onTap: onTap, borderRadius: BorderRadius.circular(6), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ if (hasCheck) const Icon(Icons.check, color: Colors.white70, size: 16) else Icon(icon, color: Colors.white54, size: 16), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( label, style: const TextStyle(color: Colors.white, fontSize: 13), ), if (subtitle != null) ...[ const SizedBox(height: 1), Text( subtitle, style: const TextStyle( color: Colors.white38, fontSize: 11, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ], ), ), if (badge != null) ...[ Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( color: Colors.white12, borderRadius: BorderRadius.circular(8), ), child: Text( badge, style: const TextStyle(color: Colors.white54, fontSize: 11), ), ), const SizedBox(width: 4), ], if (showArrow) const Icon(Icons.chevron_right, color: Colors.white24, size: 16), ], ), ), ); } Widget _buildSettingsDrawer() { return PlayerDrawerPanel( title: '显示设置', onBack: _back, child: Column( mainAxisSize: MainAxisSize.min, children: [ PanelToggleRow( label: '固定弹幕转滚动', value: s.fixedToScroll, onChanged: (v) => _emit(s.copyWith(fixedToScroll: v)), ), PanelStepperRow( label: '弹幕行数', display: s.maxRows == 0 ? '自动' : '${s.maxRows} 行', onDecrement: s.maxRows > 0 ? () => _emit(s.copyWith(maxRows: s.maxRows - 1)) : null, onIncrement: s.maxRows < 10 ? () => _emit(s.copyWith(maxRows: s.maxRows + 1)) : null, ), const FDivider( style: .delta( color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.zero), ), ), const SizedBox(height: 6), PanelSliderRow( label: '延迟显示', value: s.offset, min: -10.0, max: 10.0, step: 0.5, display: '${s.offset >= 0 ? '+' : ''}${s.offset.toStringAsFixed(1)}s', onChanged: (v) => _emit(s.copyWith(offset: _round(v, -10.0, 10.0))), ), PanelSliderRow( label: '行间距', value: s.lineHeight, min: 1.0, max: 3.0, step: 0.1, display: s.lineHeight.toStringAsFixed(1), onChanged: (v) => _emit(s.copyWith(lineHeight: _round(v, 1.0, 3.0))), ), PanelSliderRow( label: '基础速度', value: s.speed, min: 0.25, max: 2.0, step: 0.25, display: '${s.speed.toStringAsFixed(2)}x', onChanged: (v) => _emit(s.copyWith(speed: v.clamp(0.25, 2.0))), ), PanelSliderRow( label: '字号大小', value: s.fontSize, min: 12, max: 40, step: 2, display: s.fontSize.round().toString(), onChanged: (v) => _emit(s.copyWith(fontSize: _round(v, 12, 40))), ), PanelToggleRow( label: '粗体', value: s.bold, onChanged: (v) => _emit(s.copyWith(bold: v)), ), const FDivider( style: .delta( color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.zero), ), ), const SizedBox(height: 6), PanelSliderRow( label: '不透明度', value: s.opacity, min: 0.1, max: 1.0, step: 0.1, display: '${(s.opacity * 100).round()}%', onChanged: (v) => _emit(s.copyWith(opacity: _round(v, 0.1, 1.0))), ), PanelSliderRow( label: '描边大小', value: s.strokeWidth, min: 0.0, max: 3.0, step: 0.5, display: s.strokeWidth.toStringAsFixed(1), onChanged: (v) => _emit(s.copyWith(strokeWidth: _round(v, 0.0, 3.0))), ), const FDivider( style: .delta( color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.zero), ), ), _fontFamilyRow(), const SizedBox(height: 8), _resetButton(), const SizedBox(height: 4), ], ), ); } static double _round(double v, double min, double max) => (v.clamp(min, max) * 10).round() / 10; static const _fontPresets = [ (label: '系统默认', value: null), (label: 'PingFang SC', value: 'PingFang SC'), (label: 'Heiti SC', value: 'Heiti SC'), (label: 'Arial', value: 'Arial'), ]; Widget _fontFamilyRow() { final current = s.fontFamily; final label = _fontPresets.where((p) => p.value == current).firstOrNull?.label ?? '系统默认'; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( children: [ const Expanded( child: Text( '字体', style: TextStyle(color: Colors.white70, fontSize: 13), ), ), GestureDetector( onTap: _showFontPicker, child: Row( mainAxisSize: MainAxisSize.min, children: [ Text( label, style: const TextStyle( color: Colors.lightBlueAccent, fontSize: 13, ), ), const SizedBox(width: 2), const Icon( Icons.unfold_more, color: Colors.lightBlueAccent, size: 14, ), ], ), ), ], ), ); } void _showFontPicker() { showModalBottomSheet( context: context, backgroundColor: const Color(0xFF252525), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(14)), ), builder: (_) => Column( mainAxisSize: MainAxisSize.min, children: [ const Padding( padding: EdgeInsets.fromLTRB(20, 16, 20, 8), child: Text( '选择字体', style: TextStyle( color: Colors.white, fontSize: 15, fontWeight: FontWeight.w600, ), ), ), for (final p in _fontPresets) ListTile( title: Text( p.label, style: TextStyle( color: Colors.white, fontFamily: p.value, fontSize: 14, ), ), trailing: s.fontFamily == p.value ? const Icon(Icons.check, color: Colors.white70, size: 18) : null, onTap: () { Navigator.pop(context); _emit(s.copyWith(fontFamily: p.value)); }, ), const SizedBox(height: 16), ], ), ); } Widget _resetButton() { return SizedBox( width: double.infinity, child: TextButton( onPressed: () => _emit(defaultDanmakuPanelSettings()), style: TextButton.styleFrom( foregroundColor: Colors.white54, backgroundColor: Colors.white10, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(vertical: 10), ), child: const Text('重置', style: TextStyle(fontSize: 13)), ), ); } Widget _buildFilterDrawer() { return PlayerDrawerPanel( title: '弹幕过滤', onBack: _back, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ _keywordInput(), const SizedBox(height: 8), const FDivider( style: .delta( color: Color(0x1AFFFFFF), padding: .value(EdgeInsets.zero), ), ), const SizedBox(height: 4), _keywordList(), const SizedBox(height: 4), ], ), ); } Widget _keywordInput() { return Container( decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(8), border: Border.all(color: Colors.white12), ), child: Row( children: [ const Padding( padding: EdgeInsets.only(left: 10), child: Icon( Icons.filter_alt_outlined, color: Colors.white38, size: 16, ), ), Expanded( child: TextField( controller: _keywordController, style: const TextStyle(color: Colors.white, fontSize: 13), decoration: const InputDecoration( hintText: '输入要过滤的关键词', hintStyle: TextStyle(color: Colors.white38, fontSize: 13), border: InputBorder.none, contentPadding: EdgeInsets.symmetric( horizontal: 10, vertical: 10, ), isDense: true, ), onSubmitted: _addKeyword, inputFormatters: [LengthLimitingTextInputFormatter(30)], ), ), IconButton( icon: const Icon(Icons.add_circle_outline, size: 18), color: Colors.white54, padding: const EdgeInsets.only(right: 8), constraints: const BoxConstraints(minWidth: 32, minHeight: 32), onPressed: () => _addKeyword(_keywordController.text), ), ], ), ); } void _addKeyword(String raw) { final kw = raw.trim(); if (kw.isEmpty) return; if (widget.blockedKeywords.contains(kw)) { _keywordController.clear(); return; } widget.onAddKeyword(kw); _keywordController.clear(); } Widget _keywordList() { final keywords = widget.blockedKeywords; if (keywords.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Center( child: Text( '暂无屏蔽关键词', style: TextStyle(color: Colors.white38, fontSize: 12), ), ), ); } return Column( mainAxisSize: MainAxisSize.min, children: [ for (final kw in keywords) Padding( padding: const EdgeInsets.only(bottom: 4), child: Row( children: [ const Icon( Icons.label_outline, color: Colors.white38, size: 14, ), const SizedBox(width: 8), Expanded( child: Text( kw, style: const TextStyle(color: Colors.white70, fontSize: 13), ), ), IconButton( icon: const Icon(Icons.close, size: 14), color: Colors.white38, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 28, minHeight: 28, ), onPressed: () => widget.onRemoveKeyword(kw), ), ], ), ), ], ); } Widget _buildSearchDrawer() { return PlayerDrawerPanel( title: '搜索弹幕', onBack: _back, child: Column( mainAxisSize: MainAxisSize.min, children: [ _matchesContent(), const SizedBox(height: 8), _manualSearchTrigger(), const SizedBox(height: 4), ], ), ); } Widget _matchesContent() { if (widget.isLoading) { return const Padding( padding: EdgeInsets.symmetric(vertical: 16), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ AppLoadingRing(size: 14, color: Colors.white54), SizedBox(width: 8), Text( '正在匹配弹幕源…', style: TextStyle(color: Colors.white54, fontSize: 12), ), ], ), ); } final matches = widget.matches; if (matches.isEmpty) { return const Padding( padding: EdgeInsets.symmetric(vertical: 12), child: Row( children: [ Icon(Icons.info_outline, color: Colors.white24, size: 14), SizedBox(width: 6), Expanded( child: Text( '未匹配到弹幕源,可手动搜索', style: TextStyle(color: Colors.white38, fontSize: 12), ), ), ], ), ); } return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(bottom: 6), child: Text( '弹幕源 (${matches.length} 条)', style: const TextStyle(color: Colors.white38, fontSize: 11), ), ), ...List.generate(matches.length, (i) { final m = matches[i]; final selected = i == widget.selectedMatchIndex; return DanmakuMatchTile( candidate: m, selected: selected, onTap: () => widget.onMatchSelected(i), ); }), ], ); } Widget _manualSearchTrigger() { return SizedBox( width: double.infinity, height: 40, child: OutlinedButton.icon( onPressed: widget.onOpenSearch, style: OutlinedButton.styleFrom( alignment: Alignment.centerLeft, foregroundColor: Colors.white, side: const BorderSide(color: Color(0x664FA8FF)), backgroundColor: const Color(0x10FFFFFF), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)), padding: const EdgeInsets.symmetric(horizontal: 12), ), icon: const Icon(Icons.manage_search, size: 16), label: const Row( children: [ Expanded( child: Text( '搜索并手动选择分集', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500), ), ), Icon(Icons.chevron_right, size: 14, color: Colors.white38), ], ), ), ); } }