二次开发源码提交

This commit is contained in:
2026-05-15 08:52:48 +08:00
parent 4131f7321a
commit eb35a1d067
191 changed files with 34566 additions and 0 deletions
@@ -0,0 +1,409 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
/// 音频预览页面
class AudioPreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const AudioPreviewPage({super.key, required this.file, this.entityId});
@override
State<AudioPreviewPage> createState() => _AudioPreviewPageState();
}
class _AudioPreviewPageState extends State<AudioPreviewPage> {
late final Player player;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
player = Player();
_loadAudioUrl();
}
Future<void> _loadAudioUrl() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: false,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
if (mounted) {
setState(() {
_isLoading = false;
});
player.open(Media(url), play: true);
}
} else {
if (mounted) {
setState(() {
_errorMessage = '无法获取音频URL';
_isLoading = false;
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
void dispose() {
player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A1A2E),
appBar: AppBar(
title: Text(
widget.file.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
backgroundColor: const Color(0xFF1A1A2E),
elevation: 0,
iconTheme: const IconThemeData(color: Colors.white),
titleTextStyle: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w500,
),
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator(color: Colors.white),
);
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: Colors.white54,
),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white54),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadAudioUrl();
},
child: const Text('重试'),
),
],
),
);
}
return AudioPlayerWidget(player: player, fileName: widget.file.name);
}
}
/// 音频播放器组件
class AudioPlayerWidget extends StatefulWidget {
final Player player;
final String fileName;
const AudioPlayerWidget({super.key, required this.player, required this.fileName});
@override
State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
}
class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
const Color(0xFF1A1A2E),
const Color(0xFF16213E),
],
),
),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 专辑封面
Container(
width: 280,
height: 280,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFE94560), Color(0xFF533483)],
),
boxShadow: [
BoxShadow(
color: const Color(0xFFE94560).withValues(alpha: 0.3),
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
child: const Icon(
Icons.music_note,
size: 120,
color: Colors.white,
),
),
const SizedBox(height: 48),
// 文件名
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
widget.fileName,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: Colors.white,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(height: 8),
// 文件类型
Text(
'音频文件',
style: TextStyle(
fontSize: 14,
color: Colors.white.withValues(alpha: 0.6),
),
),
const SizedBox(height: 48),
// 进度条
_buildProgressBar(),
const SizedBox(height: 32),
// 播放控制
_buildPlaybackControls(),
],
),
),
);
}
Widget _buildProgressBar() {
return Column(
children: [
StreamBuilder(
stream: widget.player.stream.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
return StreamBuilder(
stream: widget.player.stream.duration,
builder: (context, snapshot) {
final duration = snapshot.data ?? Duration.zero;
return SliderTheme(
data: SliderThemeData(
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
activeTrackColor: const Color(0xFFE94560),
inactiveTrackColor: Colors.white.withValues(alpha: 0.1),
thumbColor: const Color(0xFFE94560),
overlayColor: const Color(0xFFE94560).withValues(alpha: 0.3),
),
child: Slider(
value: position.inMilliseconds.toDouble(),
max: duration.inMilliseconds > 0
? duration.inMilliseconds.toDouble()
: 1.0,
onChanged: (value) {
widget.player.seek(
Duration(milliseconds: value.toInt()),
);
},
),
);
},
);
},
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: StreamBuilder(
stream: widget.player.stream.position,
builder: (context, snapshot) {
final position = snapshot.data ?? Duration.zero;
return StreamBuilder(
stream: widget.player.stream.duration,
builder: (context, snapshot) {
final duration = snapshot.data ?? Duration.zero;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
_formatDuration(position),
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.6),
),
),
Text(
_formatDuration(duration),
style: TextStyle(
fontSize: 13,
color: Colors.white.withValues(alpha: 0.6),
),
),
],
);
},
);
},
),
),
],
);
}
Widget _buildPlaybackControls() {
final screenWidth = MediaQuery.of(context).size.width;
final isSmallScreen = screenWidth < 400;
return Padding(
padding: EdgeInsets.symmetric(horizontal: isSmallScreen ? 8 : 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 上一首(暂无功能)
Flexible(
child: IconButton(
icon: const Icon(Icons.skip_previous),
iconSize: isSmallScreen ? 28 : 36,
color: Colors.white,
onPressed: () {},
),
),
// 快退10秒
Flexible(
child: IconButton(
icon: const Icon(Icons.replay_10),
iconSize: isSmallScreen ? 32 : 42,
color: Colors.white,
onPressed: () {
final position = widget.player.state.position;
widget.player.seek(position - const Duration(seconds: 10));
},
),
),
// 播放/暂停
StreamBuilder(
stream: widget.player.stream.playing,
builder: (context, snapshot) {
final playing = snapshot.data ?? false;
return Container(
width: isSmallScreen ? 56 : 72,
height: isSmallScreen ? 56 : 72,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: const Color(0xFFE94560),
boxShadow: [
BoxShadow(
color: const Color(0xFFE94560).withValues(alpha: 0.4),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: IconButton(
icon: Icon(playing ? Icons.pause : Icons.play_arrow),
iconSize: isSmallScreen ? 28 : 36,
color: Colors.white,
onPressed: () {
if (playing) {
widget.player.pause();
} else {
widget.player.play();
}
},
),
);
},
),
// 快进10秒
Flexible(
child: IconButton(
icon: const Icon(Icons.forward_10),
iconSize: isSmallScreen ? 32 : 42,
color: Colors.white,
onPressed: () {
final position = widget.player.state.position;
widget.player.seek(position + const Duration(seconds: 10));
},
),
),
// 下一首(暂无功能)
Flexible(
child: IconButton(
icon: const Icon(Icons.skip_next),
iconSize: isSmallScreen ? 28 : 36,
color: Colors.white,
onPressed: () {},
),
),
],
),
);
}
String _formatDuration(Duration duration) {
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
}
@@ -0,0 +1,256 @@
import 'dart:ui';
import 'package:cloudreve4_flutter/core/utils/language_preview.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:code_text_field/code_text_field.dart';
import 'package:flutter_highlight/themes/atom-one-dark.dart';
import 'package:highlight/highlight.dart';
import 'package:highlight/languages/all.dart';
import 'package:http/http.dart' as http;
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
import '../../../core/utils/file_type_utils.dart';
/// 文档预览页面
class DocumentPreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const DocumentPreviewPage({super.key, required this.file, this.entityId});
@override
State<DocumentPreviewPage> createState() => _DocumentPreviewPageState();
}
class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
CodeController? _codeController;
static const _backgroundColor = Color(0xFF292d3e);
String _content = '';
bool _isLoading = true;
String? _error;
Mode _languageMode = allLanguages['plaintext']!;
String _languageName = 'Text';
int _lineCount = 0;
final ScrollController _customCodeScrollController = ScrollController();
// 状态管理
bool _showLineNumbers = true;
double _fontSize = 14.0; // 默认字体大小
bool _hasInitializedLayout = false;
@override
void initState() {
super.initState();
_loadFileContent();
}
@override
void dispose() {
_codeController?.dispose();
_customCodeScrollController.dispose();
super.dispose();
}
// 加载逻辑保持不变...
Future<void> _loadFileContent() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: true,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isEmpty) throw Exception('获取URL为空');
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
final responseContent = await http.get(Uri.parse(url));
if (responseContent.statusCode != 200) {
throw Exception('下载文件失败: ${responseContent.statusCode}');
}
if (mounted) {
setState(() {
_content = responseContent.body;
_lineCount = _countLines(_content);
_languageMode = _detectLanguageMode(widget.file.name);
_languageName = _getLanguageNameFromExtension(widget.file.name);
_initCodeController();
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
throw Exception('获取文件内容失败: $_error');
});
}
}
}
void _initCodeController() {
_codeController = CodeController(text: _content, language: _languageMode);
}
int _countLines(String text) => text.isEmpty ? 0 : '\n'.allMatches(text).length + 1;
Mode _detectLanguageMode(String fileName) {
final ext = FileTypeUtils.getExtension(fileName);
final extLang = LanguagePreview.getExtNameMapping[ext] ?? ext.toUpperCase();
return allLanguages[extLang.toLowerCase()] ?? allLanguages['plaintext']!;
}
String _getLanguageNameFromExtension(String fileName) {
final ext = FileTypeUtils.getExtension(fileName).toLowerCase();
return LanguagePreview.getExtNameMapping[ext] ?? ext.toUpperCase();
}
@override
Widget build(BuildContext context) {
final double screenWidth = MediaQuery.of(context).size.width;
// 自动初始化布局逻辑
if (!_hasInitializedLayout && !_isLoading) {
_showLineNumbers = screenWidth >= 600;
_hasInitializedLayout = true;
}
// --- 动态行号宽度计算 (核心修复) ---
double lineNumberWidth = 0;
if (_showLineNumbers) {
// 计算行数的位数,并根据当前字体大小分配宽度
int digits = _lineCount.toString().length;
// 这里的 0.7 是字体宽高的约数比例,20 是左右边距预留
lineNumberWidth = (digits * (_fontSize * 0.7)) + 15;
if (lineNumberWidth < 35) lineNumberWidth = 35;
}
return Scaffold(
backgroundColor: _backgroundColor,
appBar: AppBar(
backgroundColor: _backgroundColor,
elevation: 0,
iconTheme: const IconThemeData(
color: Colors.white,
),
title: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.file.name, style: const TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center,),
if (!_isLoading)
Text('$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12), textAlign: TextAlign.center,),
],
),
actions: [
IconButton(
icon: const Icon(Icons.copy, color: Colors.white),
onPressed: () => Clipboard.setData(ClipboardData(text: _content)),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Colors.white))
: _buildCodeEditor(lineNumberWidth),
floatingActionButton: _buildExpandableFab(),
);
}
/// 现在是一次性渲染的, 所以代码行数太多会有性能问题, 渲染会耗时较长, 而且会卡顿
Widget _buildCodeEditor(double lineNumberWidth) {
// 核心改进:根据当前字号,动态计算一个更宽松的行号宽度
// 13号字大概每个数字占 8-9 像素,我们按 10 像素算并加上边距
int digits = _lineCount.toString().length;
// 这里的 12.0 是根据 13-15号字体的平均宽度预留,46 是基础间距 (调试火葬场)
double stableWidth = _showLineNumbers
? (digits * (_fontSize * 0.75)) + 46
: 0;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: _backgroundColor,
borderRadius: BorderRadius.circular(12),
),
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context).copyWith(
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
),
child: CodeTheme(
data: CodeThemeData(styles: {...atomOneDarkTheme}),
child: Scrollbar(
controller: _customCodeScrollController,
child: SingleChildScrollView(
controller: _customCodeScrollController,
child: CodeField(
controller: _codeController!,
textStyle: TextStyle(
fontFamily: 'SourceCodePro',
fontSize: _fontSize,
height: 1.5,
),
enabled: false,
readOnly: true,
background: Colors.transparent,
// 关键点 1:调整行号样式
lineNumberStyle: LineNumberStyle(
width: stableWidth,
textAlign: TextAlign.right, // 回归右对齐,更符合代码习惯
margin: _showLineNumbers ? 12 : 0, // 增加间距防止数字贴边触发换行
textStyle: TextStyle(
color: _showLineNumbers ? Colors.grey.shade600 : Colors.transparent,
fontSize: _fontSize * 0.8,
height: 1.5, // 必须和正文高度完全一致
// 核心修复:通过强制单词不换行来防止数字断裂
fontFeatures: const [FontFeature.tabularFigures()], // 使用等宽数字
),
),
),
),
),
),
),
);
}
// 构建功能组合按钮
Widget _buildExpandableFab() {
if (_isLoading) return const SizedBox.shrink();
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 增加字号
FloatingActionButton(
heroTag: 'font_up',
mini: true,
backgroundColor: Colors.grey.shade800,
onPressed: () => setState(() { if(_fontSize < 30) _fontSize++; }),
child: const Icon(Icons.add, color: Colors.white, size: 20),
),
const SizedBox(height: 8),
// 减小字号
FloatingActionButton(
heroTag: 'font_down',
mini: true,
backgroundColor: Colors.grey.shade800,
onPressed: () => setState(() { if(_fontSize > 8) _fontSize--; }),
child: const Icon(Icons.remove, color: Colors.white, size: 20),
),
const SizedBox(height: 8),
// 行号开关
FloatingActionButton(
heroTag: 'line_toggle',
mini: true,
backgroundColor: _showLineNumbers ? Colors.blue : Colors.grey.shade800,
onPressed: () => setState(() => _showLineNumbers = !_showLineNumbers),
child: Icon(
_showLineNumbers ? Icons.format_list_numbered : Icons.short_text,
color: Colors.white,
size: 20,
),
),
],
);
}
}
@@ -0,0 +1,223 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
import '../../../services/cache_manager_service.dart';
import '../../widgets/toast_helper.dart';
/// 图片预览页面
class ImagePreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const ImagePreviewPage({super.key, required this.file, this.entityId});
@override
State<ImagePreviewPage> createState() => _ImagePreviewPageState();
}
class _ImagePreviewPageState extends State<ImagePreviewPage> {
String? _imageUrl;
bool _isLoading = true;
String? _errorMessage;
// 定义一个变量,防止多个动画冲突
bool _isAnimating = false;
@override
void initState() {
super.initState();
_loadImageUrl();
}
Future<void> _loadImageUrl() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: false,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
if (mounted) {
setState(() {
_imageUrl = url;
_isLoading = false;
});
}
} else {
if (mounted) {
setState(() {
_errorMessage = '无法获取图片URL';
_isLoading = false;
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.file.name),
actions: [
PopupMenuButton<String>(
onSelected: (value) {
if (value == 'bingo') {
_bingoHahah();
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'bingo',
child: Row(
children: [
Icon(Icons.handshake, size: 20),
SizedBox(width: 12),
Text('bingo'),
],
),
),
],
),
],
),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadImageUrl();
},
child: const Text('重试'),
),
],
),
);
}
if (_imageUrl == null) {
return const Center(child: Text('无法加载图片'));
}
return _buildPhotoView();
}
void _bingoHahah() {
ToastHelper.info('彩蛋彩蛋彩蛋蛋, 对下联');
}
Listener _buildPhotoView() {
// 1. 自定义控制器, 用于支持Linux/Windows 键盘 Ctrl + 鼠标滚轮缩放图片
final PhotoViewController photoController = PhotoViewController();
// 2. 包装组件
return Listener(
onPointerSignal: (pointerSignal) {
if (pointerSignal is PointerScrollEvent) {
// 检查是否按下了 Ctrl 键 (在 Linux/Windows 上很常用)
// 如果你希望直接滚动滚轮就缩放,可以去掉 RawKeyboardGui... 这一行判断
final isControlPressed =
HardwareKeyboard.instance.logicalKeysPressed.contains(
LogicalKeyboardKey.controlLeft,
) ||
HardwareKeyboard.instance.logicalKeysPressed.contains(
LogicalKeyboardKey.controlRight,
);
// 计算缩放增量:向上滚为负,向下滚为正
// 这里的 0.001 是灵敏度系数,可以根据手感调整
if (isControlPressed) {
if (photoController.scale == null) return;
// double newScale =
// photoController.scale! - (pointerSignal.scrollDelta.dy * 0.001);
// 限制缩放范围,防止无限缩小或放大
_smoothScale(photoController, pointerSignal.scrollDelta.dy);
}
}
},
child: PhotoView(
controller: photoController, // 绑定控制器
imageProvider: CachedNetworkImageProvider(
_imageUrl!,
cacheManager: CacheManagerService.instance.manager,
),
minScale: PhotoViewComputedScale.contained,
maxScale: PhotoViewComputedScale.covered * 3,
initialScale: PhotoViewComputedScale.contained,
backgroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
loadingBuilder: (context, event) =>
const Center(child: CircularProgressIndicator()),
errorBuilder: (context, error, stackTrace) => const Center(
child: Icon(Icons.error_outline, size: 48, color: Colors.red),
),
),
);
}
// 2. 编写平滑缩放函数
void _smoothScale(PhotoViewController photoController, double delta) {
// 如果正在动画中,忽略新的滚轮脉冲,防止冲突
if (_isAnimating) return;
_isAnimating = true;
// 计算目标缩放值
double targetScale = (photoController.scale ?? 1.0) - (delta * 0.001);
targetScale = targetScale.clamp(0.1, 5.0);
// 如果不想写复杂的 AnimationController,可以用这种简易插值
// 这里的 10 次循环和 5 毫秒延迟可以根据你的手感微调
int steps = 5;
double stepDelta = (targetScale - photoController.scale!) / steps;
Future.doWhile(() async {
if (steps <= 0) {
_isAnimating = false;
return false;
}
photoController.scale = photoController.scale! + stepDelta;
steps--;
await Future.delayed(const Duration(milliseconds: 16)); // 约 60 帧的速度
return true;
});
}
}
@@ -0,0 +1,311 @@
import 'package:cloudreve4_flutter/presentation/widgets/code_wrapper.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_highlight/themes/a11y-light.dart';
import 'package:flutter_highlight/themes/atom-one-dark.dart';
import 'package:http/http.dart' as http;
import 'package:markdown_widget/config/configs.dart';
import 'package:markdown_widget/config/toc.dart';
import 'package:markdown_widget/widget/blocks/leaf/code_block.dart';
import 'package:markdown_widget/widget/blocks/leaf/link.dart';
import 'package:markdown_widget/widget/markdown.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
class MarkdownPreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const MarkdownPreviewPage({super.key, required this.file, this.entityId});
@override
State<MarkdownPreviewPage> createState() => _MarkdownPreviewPageState();
}
class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
String _content = '';
bool _isLoading = true;
String? _error;
final ScrollController _scrollController = ScrollController();
final _tocController = TocController();
bool _isDarkMode = false;
// 控制目录是否显示
bool _isTocVisible = false;
// 标记是否已经根据屏幕宽度进行了初始化
bool _hasInitializedLayout = false;
@override
void initState() {
super.initState();
_loadFileContent();
}
@override
void dispose() {
_scrollController.dispose();
_tocController.dispose();
super.dispose();
}
Future<void> _loadFileContent() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: true,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isEmpty) throw Exception('获取URL为空');
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
final responseContent = await http.get(Uri.parse(url));
if (responseContent.statusCode != 200) {
throw Exception('下载文件失败: ${responseContent.statusCode}');
}
if (mounted) {
setState(() {
_content = responseContent.body;
_isLoading = false;
});
}
} catch (e) {
if (mounted) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final double screenWidth = MediaQuery.of(context).size.width;
final bool isWideScreen = screenWidth >= 1000;
// 首次加载时,根据屏幕宽度决定目录默认状态
if (!_hasInitializedLayout && !_isLoading) {
_isTocVisible = isWideScreen;
_hasInitializedLayout = true;
}
final isDark = _isDarkMode;
final bgColor = isDark ? const Color(0xFF1E1E1E) : Colors.white;
return Scaffold(
backgroundColor: bgColor,
appBar: AppBar(
backgroundColor: bgColor,
elevation: 0,
leading: IconButton(
icon: Icon(Icons.arrow_back, color: isDark ? Colors.white : Colors.black87),
onPressed: () => Navigator.of(context).pop(),
),
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
widget.file.name,
style: TextStyle(
color: isDark ? Colors.white : Colors.black87,
fontSize: 16,
fontWeight: FontWeight.w500,
),
overflow: TextOverflow.ellipsis,
),
if (!_isLoading && _error == null)
Text('Markdown 预览', style: TextStyle(color: Colors.grey, fontSize: 12)),
],
),
actions: [
if (!_isLoading && _error == null)
IconButton(
icon: Icon(Icons.copy, color: isDark ? Colors.white : Colors.black87),
onPressed: () => Clipboard.setData(ClipboardData(text: _content)),
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _error != null
? _buildErrorWidget()
: _buildResponsiveBody(isWideScreen, screenWidth),
floatingActionButton: _buildFAB(isDark),
);
}
// 构建响应式主体
Widget _buildResponsiveBody(bool isWideScreen, double screenWidth) {
final double tocWidth = isWideScreen ? 300 : screenWidth * 0.7;
// 预定义暗色/亮色下的文字颜色
final Color textColor = _isDarkMode ? Colors.white : Colors.black87;
final Color subTextColor = _isDarkMode ? Colors.white70 : Colors.black54;
return Row(
children: [
// 1. 侧边目录区
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _isTocVisible ? tocWidth : 0,
child: Container(
decoration: BoxDecoration(
border: Border(
right: BorderSide(
color: _isDarkMode ? Colors.white10 : Colors.grey.withValues(alpha: 0.2)
),
),
color: _isDarkMode ? const Color(0xFF252525) : Colors.grey.shade50,
),
child: ClipRect(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 8),
child: Text(
"目录",
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: textColor, // 适配标题颜色
),
),
),
Divider(height: 1, color: _isDarkMode ? Colors.white10 : Colors.grey.shade300),
Expanded(
child: GestureDetector(
onTap: () {
if (!isWideScreen) {
Future.delayed(const Duration(milliseconds: 200), () {
if (mounted) setState(() => _isTocVisible = false);
});
}
},
behavior: HitTestBehavior.translucent,
// 使用 Theme 局部包裹,强制改变 TOC 内部的 TextTheme
child: Theme(
data: Theme.of(context).copyWith(
textTheme: TextTheme(
// 某些版本的 markdown_widget 会引用 bodyMedium 或 bodySmall
bodyMedium: TextStyle(color: subTextColor),
bodySmall: TextStyle(color: subTextColor),
),
),
child: TocWidget(
controller: _tocController,
),
),
),
),
],
),
),
),
),
// 2. 正文内容区
Expanded(
child: Container(
color: _isDarkMode ? const Color(0xFF1E1E1E) : Colors.white,
child: SafeArea(
left: false,
right: true,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0), // 增加一点边距
child: _buildMarkdownWidget(_content),
),
),
),
),
],
);
}
Widget _buildMarkdownWidget(String content) {
final config = _isDarkMode ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig;
CodeWrapperWidget codeWrapper(child, text, language) => CodeWrapperWidget(child, text, language);
return MarkdownWidget(
data: content,
tocController: _tocController,
config: config.copy(
configs: [
_isDarkMode
? PreConfig.darkConfig.copy(theme: a11yLightTheme, wrapper: codeWrapper)
: PreConfig(theme: atomOneDarkTheme).copy(wrapper: codeWrapper),
LinkConfig(
style: TextStyle(
color: _isDarkMode ? Colors.lightBlue : Colors.blue,
decoration: TextDecoration.underline,
),
onTap: (url) => launchUrl(Uri.parse(url)),
),
],
),
selectable: true,
);
}
Widget _buildErrorWidget() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(_error!),
ElevatedButton(onPressed: _loadFileContent, child: const Text('重试')),
],
),
);
}
Widget _buildFAB(bool isDark) {
if (_isLoading || _error != null) return const SizedBox.shrink();
// 定义统一的按钮背景颜色,增加视觉一致性
final Color activeColor = isDark ? Colors.blue.shade700 : Colors.blue;
final Color inactiveColor = isDark ? Colors.grey.shade800 : Colors.grey.shade200;
final Color iconColor = isDark ? Colors.white : Colors.black87;
return Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
// 1. 暗色模式切换按钮
FloatingActionButton(
heroTag: 'theme_toggle',
mini: true, // 保持 mini
backgroundColor: inactiveColor,
elevation: 2, // 稍微降低阴影,看起来更精致
onPressed: () => setState(() => _isDarkMode = !_isDarkMode),
child: Icon(
isDark ? Icons.light_mode : Icons.dark_mode,
color: iconColor,
size: 20, // 微调图标大小
),
),
const SizedBox(height: 12),
// 2. 目录切换按钮
FloatingActionButton(
heroTag: 'toc_toggle',
mini: true, // 这里修改为 true,使其与上面的按钮大小一致
// 如果目录开启,使用蓝色背景,否则使用普通背景
backgroundColor: _isTocVisible ? activeColor : inactiveColor,
elevation: 2,
onPressed: () => setState(() => _isTocVisible = !_isTocVisible),
child: Icon(
Icons.format_list_bulleted,
color: _isTocVisible ? Colors.white : iconColor,
size: 20,
),
),
],
);
}
}
@@ -0,0 +1,129 @@
import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
/// PDF预览页面
class PdfPreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const PdfPreviewPage({super.key, required this.file, this.entityId});
@override
State<PdfPreviewPage> createState() => _PdfPreviewPageState();
}
class _PdfPreviewPageState extends State<PdfPreviewPage> {
String? _pdfUrl;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
_loadPdfUrl();
}
Future<void> _loadPdfUrl() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: false,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
if (mounted) {
setState(() {
_pdfUrl = url;
_isLoading = false;
});
}
} else {
if (mounted) {
setState(() {
_errorMessage = '无法获取PDF URL';
_isLoading = false;
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(widget.file.name)),
body: _buildBody(),
);
}
Widget _buildBody() {
if (_isLoading) {
return const Center(child: CircularProgressIndicator());
}
if (_errorMessage != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadPdfUrl();
},
child: const Text('重试'),
),
],
),
);
}
if (_pdfUrl == null) {
return const Center(child: Text('无法加载PDF'));
}
return Container(
color: Colors.grey.shade200,
child: PdfViewer.uri(
Uri.parse(_pdfUrl!),
initialPageNumber: 1,
params: const PdfViewerParams(
activeMatchTextColor: Colors.yellow,
annotationRenderingMode: PdfAnnotationRenderingMode.annotationAndForms,
maxScale: 4.0,
minScale: 0.8, // Allow 300% zoom
scaleEnabled: true,
textSelectionParams: PdfTextSelectionParams(
enabled: true,
showContextMenuAutomatically: true,
),
),
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import '../../../data/models/file_model.dart';
import '../../../services/file_service.dart';
import 'widgets/video_controls_overlay.dart';
/// 视频预览页面
class VideoPreviewPage extends StatefulWidget {
final FileModel file;
final String? entityId;
const VideoPreviewPage({super.key, required this.file, this.entityId});
@override
State<VideoPreviewPage> createState() => _VideoPreviewPageState();
}
class _VideoPreviewPageState extends State<VideoPreviewPage> {
late final Player player;
late final VideoController controller;
bool _isLoading = true;
String? _errorMessage;
@override
void initState() {
super.initState();
player = Player();
controller = VideoController(player);
_loadVideoUrl();
}
Future<void> _loadVideoUrl() async {
try {
final response = await FileService().getDownloadUrls(
uris: [widget.file.relativePath],
download: false,
entity: widget.entityId,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String;
if (mounted) {
setState(() => _isLoading = false);
player.open(Media(url), play: true);
}
} else {
if (mounted) {
setState(() {
_errorMessage = '无法获取视频URL';
_isLoading = false;
});
}
}
} catch (e) {
if (mounted) {
setState(() {
_errorMessage = e.toString();
_isLoading = false;
});
}
}
}
@override
void dispose() {
player.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Colors.white))
: _errorMessage != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.white),
const SizedBox(height: 16),
Text(
_errorMessage!,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white70),
),
const SizedBox(height: 16),
FilledButton(
onPressed: () {
setState(() {
_isLoading = true;
_errorMessage = null;
});
_loadVideoUrl();
},
child: const Text('重试'),
),
],
),
)
: ExcludeSemantics(
child: Video(
controller: controller,
controls: (state) => VideoControlsOverlay(state: state, title: widget.file.name),
),
),
);
}
}
@@ -0,0 +1,764 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:window_manager/window_manager.dart';
import '../../../../core/utils/video_fullscreen.dart';
/// 自定义视频控制栏叠加层(Bilibili 风格)
class VideoControlsOverlay extends StatefulWidget {
final VideoState state;
final String title;
const VideoControlsOverlay({super.key, required this.state, required this.title});
@override
State<VideoControlsOverlay> createState() => _VideoControlsOverlayState();
}
class _VideoControlsOverlayState extends State<VideoControlsOverlay> with WindowListener {
Player get player => widget.state.widget.controller.player;
bool _controlsVisible = true;
Timer? _hideTimer;
bool _isLongPressing = false;
double _rateBeforeLongPress = 1.0;
// 进度条拖拽状态
bool _isSeeking = false;
Duration _seekPosition = Duration.zero;
// 音量控制
bool _volumeSliderVisible = false;
double _volumeBeforeMute = 100.0;
// 自管全屏状态
bool _isFullscreen = false;
// 键盘焦点
final FocusNode _focusNode = FocusNode();
// 桌面端鼠标悬停
Timer? _mouseHoverTimer;
bool get _isDesktop => Platform.isWindows || Platform.isLinux || Platform.isMacOS;
@override
void initState() {
super.initState();
_startHideTimer();
if (_isDesktop) {
windowManager.addListener(this);
}
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNode.requestFocus();
});
}
@override
void dispose() {
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
_focusNode.dispose();
if (_isDesktop) {
windowManager.removeListener(this);
}
if (_isFullscreen) {
_doExitFullscreen();
}
super.dispose();
}
// ========== WindowListener(桌面端) ==========
@override
void onWindowEnterFullScreen() {
if (!_isFullscreen && mounted) {
setState(() => _isFullscreen = true);
}
}
@override
void onWindowLeaveFullScreen() {
if (_isFullscreen && mounted) {
setState(() => _isFullscreen = false);
videoFullscreenNotifier.value = false;
}
}
// ========== 全屏控制 ==========
Future<void> _toggleFullscreen() async {
if (_isFullscreen) {
await _doExitFullscreen();
} else {
await _doEnterFullscreen();
}
if (mounted) setState(() {});
}
Future<void> _doEnterFullscreen() async {
_isFullscreen = true;
if (_isDesktop) {
videoFullscreenNotifier.value = true;
await windowManager.setFullScreen(true);
} else {
await Future.wait([
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky, overlays: []),
SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]),
]);
}
}
Future<void> _doExitFullscreen() async {
_isFullscreen = false;
if (_isDesktop) {
await windowManager.setFullScreen(false);
videoFullscreenNotifier.value = false;
} else {
await Future.wait([
SystemChrome.setEnabledSystemUIMode(SystemUiMode.manual, overlays: SystemUiOverlay.values),
SystemChrome.setPreferredOrientations([
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
]),
]);
}
}
void _onBack() {
if (_isFullscreen) {
_doExitFullscreen().then((_) {
if (mounted) setState(() {});
});
} else {
Navigator.of(context).pop();
}
}
// ========== 控制栏显隐 ==========
void _startHideTimer() {
_hideTimer?.cancel();
_hideTimer = Timer(const Duration(seconds: 3), () {
if (mounted && !_isLongPressing) {
_hideControls();
}
});
}
void _hideControls() {
setState(() {
_controlsVisible = false;
_volumeSliderVisible = false;
});
}
void _showControls() {
setState(() {
_controlsVisible = true;
});
_startHideTimer();
}
void _toggleControls() {
if (_controlsVisible) {
_hideControls();
} else {
_showControls();
}
}
void _onLongPressStart() {
_hideTimer?.cancel();
_rateBeforeLongPress = player.state.rate;
player.setRate(2.0);
setState(() {
_isLongPressing = true;
_controlsVisible = false;
_volumeSliderVisible = false;
});
}
void _onLongPressEnd() {
player.setRate(_rateBeforeLongPress);
setState(() {
_isLongPressing = false;
_controlsVisible = true;
});
_startHideTimer();
}
// ========== 桌面端鼠标悬停 ==========
void _onMouseHover(PointerHoverEvent event) {
if (!_controlsVisible) {
setState(() => _controlsVisible = true);
}
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
_mouseHoverTimer = Timer(const Duration(seconds: 1), () {
if (mounted && !_isLongPressing && !_volumeSliderVisible && !_isSeeking) {
_hideControls();
}
});
}
void _onMouseExit(PointerExitEvent event) {
_mouseHoverTimer?.cancel();
_startHideTimer();
}
// ========== 键盘快捷键 ==========
void _onKeyEvent(KeyEvent event) {
if (event is! KeyDownEvent) return;
final key = event.logicalKey;
if (key == LogicalKeyboardKey.space) {
player.playOrPause();
} else if (key == LogicalKeyboardKey.keyM) {
_toggleMute();
} else if (key == LogicalKeyboardKey.enter || key == LogicalKeyboardKey.numpadEnter) {
_toggleFullscreen();
} else if (key == LogicalKeyboardKey.escape) {
if (_isFullscreen) _onBack();
} else if (key == LogicalKeyboardKey.arrowLeft) {
final pos = player.state.position - const Duration(seconds: 5);
player.seek(pos < Duration.zero ? Duration.zero : pos);
} else if (key == LogicalKeyboardKey.arrowRight) {
final pos = player.state.position + const Duration(seconds: 5);
final dur = player.state.duration;
player.seek(pos > dur ? dur : pos);
} else if (key == LogicalKeyboardKey.arrowUp) {
final vol = (player.state.volume + 5).clamp(0.0, 100.0);
player.setVolume(vol);
} else if (key == LogicalKeyboardKey.arrowDown) {
final vol = (player.state.volume - 5).clamp(0.0, 100.0);
player.setVolume(vol);
}
}
void _toggleMute() {
if (player.state.volume <= 0) {
player.setVolume(_volumeBeforeMute);
} else {
_volumeBeforeMute = player.state.volume;
player.setVolume(0);
}
}
// ========== 音量图标点击 ==========
void _onVolumeIconTap(double currentVolume) {
if (_volumeSliderVisible) {
// 滑条已展开,再次点击小喇叭 → 静音
if (currentVolume <= 0) {
player.setVolume(_volumeBeforeMute);
} else {
_volumeBeforeMute = currentVolume;
player.setVolume(0);
}
} else {
// 滑条未展开,展开滑条
_hideTimer?.cancel();
setState(() => _volumeSliderVisible = true);
}
}
// ========== 倍速菜单 ==========
void _showSpeedMenu() {
_hideTimer?.cancel();
final rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
final currentRate = player.state.rate;
showModalBottomSheet(
context: context,
backgroundColor: Colors.black87,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(12)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(12),
child: Text('倍速播放',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
color: Colors.white70,
)),
),
const Divider(height: 1, color: Colors.white24),
Wrap(
children: rates.map((rate) {
final selected = (rate - currentRate).abs() < 0.01;
return SizedBox(
width: (MediaQuery.of(context).size.width - 32) / 4,
child: ListTile(
title: Text(
'${rate}x',
textAlign: TextAlign.center,
style: TextStyle(
color: selected ? const Color(0xFFE94560) : Colors.white,
fontWeight: selected ? FontWeight.bold : FontWeight.normal,
fontSize: 14,
),
),
onTap: () {
player.setRate(rate);
Navigator.of(ctx).pop();
_startHideTimer();
},
),
);
}).toList(),
),
const SizedBox(height: 8),
],
),
),
);
}
// ========== 工具方法 ==========
String _formatDuration(Duration d) {
final h = d.inHours;
final m = d.inMinutes.remainder(60).toString().padLeft(2, '0');
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return h > 0 ? '$h:$m:$s' : '$m:$s';
}
// ========== 构建 UI ==========
@override
Widget build(BuildContext context) {
Widget overlay = GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: _toggleControls,
onDoubleTap: () => player.playOrPause(),
onLongPressStart: (_) => _onLongPressStart(),
onLongPressEnd: (_) => _onLongPressEnd(),
child: Stack(
fit: StackFit.expand,
children: [
// 长按2倍速提示
if (_isLongPressing)
Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
decoration: BoxDecoration(
color: Colors.black54,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.fast_forward, color: Colors.white, size: 20),
const SizedBox(width: 8),
Text(
'2.0x 快进中',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 4)],
),
),
],
),
),
),
// Buffering 指示器
StreamBuilder<bool>(
stream: player.stream.buffering,
builder: (context, snapshot) {
final buffering = snapshot.data ?? false;
if (!buffering) return const SizedBox.shrink();
return const Center(
child: CircularProgressIndicator(color: Colors.white70),
);
},
),
// 控制栏
AnimatedOpacity(
opacity: _controlsVisible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: Column(
children: [
_buildTopBar(),
const Spacer(),
_buildBottomBar(),
],
),
),
),
// 桌面端快捷键提示
if (_isDesktop)
AnimatedOpacity(
opacity: _controlsVisible ? 0.4 : 0.0,
duration: const Duration(milliseconds: 250),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: _buildShortcutsHint(),
),
),
],
),
);
// 桌面端:鼠标悬停 + 键盘
if (_isDesktop) {
overlay = MouseRegion(
onHover: _onMouseHover,
onExit: _onMouseExit,
child: KeyboardListener(
focusNode: _focusNode,
onKeyEvent: _onKeyEvent,
child: overlay,
),
);
}
return PopScope(
canPop: !_isFullscreen,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) _onBack();
},
child: overlay,
);
}
Widget _buildTopBar() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.black54, Colors.transparent],
),
),
padding: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 4,
left: 4,
right: 4,
bottom: 8,
),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back, color: Colors.white),
onPressed: _onBack,
),
Expanded(
child: Text(
widget.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildBottomBar() {
return Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black54, Colors.transparent],
),
),
padding: const EdgeInsets.only(left: 8, right: 8, bottom: 4),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_buildSeekBar(),
Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).padding.bottom,
),
child: _buildControlsRow(),
),
],
),
);
}
Widget _buildControlsRow() {
return Row(
children: [
// 播放/暂停
StreamBuilder<bool>(
stream: player.stream.playing,
builder: (context, snapshot) {
final playing = snapshot.data ?? false;
return IconButton(
icon: Icon(playing ? Icons.pause : Icons.play_arrow, color: Colors.white),
onPressed: () => player.playOrPause(),
);
},
),
// 音量控制
_buildVolumeControl(),
// 时间
StreamBuilder<Duration>(
stream: player.stream.position,
builder: (context, posSnapshot) {
return StreamBuilder<Duration>(
stream: player.stream.duration,
builder: (context, durSnapshot) {
final pos = posSnapshot.data ?? Duration.zero;
final dur = durSnapshot.data ?? Duration.zero;
return Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
'${_formatDuration(pos)} / ${_formatDuration(dur)}',
style: const TextStyle(color: Colors.white70, fontSize: 12),
),
);
},
);
},
),
const Spacer(),
// 倍速按钮
StreamBuilder<double>(
stream: player.stream.rate,
builder: (context, snapshot) {
final rate = snapshot.data ?? 1.0;
return GestureDetector(
onTap: _showSpeedMenu,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
child: Text(
'${rate}x',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
),
);
},
),
// 全屏按钮
IconButton(
icon: Icon(
_isFullscreen ? Icons.fullscreen_exit : Icons.fullscreen,
color: Colors.white,
),
onPressed: _toggleFullscreen,
),
],
);
}
Widget _buildVolumeControl() {
return StreamBuilder<double>(
stream: player.stream.volume,
builder: (context, snapshot) {
final volume = snapshot.data ?? 100.0;
final isMuted = volume <= 0;
IconData volumeIcon;
if (isMuted) {
volumeIcon = Icons.volume_off;
} else if (volume < 50) {
volumeIcon = Icons.volume_down;
} else {
volumeIcon = Icons.volume_up;
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => _onVolumeIconTap(volume),
child: Padding(
padding: const EdgeInsets.all(8),
child: Icon(volumeIcon, color: Colors.white, size: 22),
),
),
AnimatedSize(
duration: const Duration(milliseconds: 200),
curve: Curves.easeInOut,
alignment: Alignment.centerLeft,
child: _volumeSliderVisible
? SizedBox(
width: 100,
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 5),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 10),
activeTrackColor: const Color(0xFFE94560),
inactiveTrackColor: Colors.white24,
thumbColor: const Color(0xFFE94560),
),
child: Slider(
value: volume.clamp(0.0, 100.0),
min: 0,
max: 100,
onChanged: (v) {
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
player.setVolume(v);
},
onChangeEnd: (_) {
_startHideTimer();
if (_isDesktop) _startMouseHoverTimer();
},
),
),
)
: const SizedBox(width: 0),
),
],
);
},
);
}
void _startMouseHoverTimer() {
_mouseHoverTimer?.cancel();
_mouseHoverTimer = Timer(const Duration(seconds: 1), () {
if (mounted && !_isLongPressing && !_volumeSliderVisible && !_isSeeking) {
_hideControls();
}
});
}
Widget _buildSeekBar() {
return StreamBuilder<Duration>(
stream: player.stream.position,
builder: (context, posSnapshot) {
return StreamBuilder<Duration>(
stream: player.stream.duration,
builder: (context, durSnapshot) {
final position = posSnapshot.data ?? Duration.zero;
final duration = durSnapshot.data ?? Duration.zero;
final displayPos = _isSeeking ? _seekPosition : position;
final value = duration.inMilliseconds > 0
? displayPos.inMilliseconds / duration.inMilliseconds
: 0.0;
return SizedBox(
height: 20,
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 12),
activeTrackColor: const Color(0xFFE94560),
inactiveTrackColor: Colors.white24,
thumbColor: const Color(0xFFE94560),
),
child: Slider(
value: value.clamp(0.0, 1.0),
onChanged: (v) {
_hideTimer?.cancel();
_mouseHoverTimer?.cancel();
setState(() {
_isSeeking = true;
_seekPosition = Duration(
milliseconds: (duration.inMilliseconds * v).round(),
);
});
},
onChangeEnd: (v) {
final seekTo = Duration(
milliseconds: (duration.inMilliseconds * v).round(),
);
player.seek(seekTo);
setState(() => _isSeeking = false);
_startHideTimer();
if (_isDesktop) _startMouseHoverTimer();
},
),
),
);
},
);
},
);
}
Widget _buildShortcutsHint() {
const shortcuts = [
('Space', '暂停/播放'),
('M', '静音'),
('Enter', '全屏'),
('Esc', '退出全屏'),
('←→', '快退/快进 5s'),
('↑↓', '音量 ±5'),
];
return Align(
alignment: Alignment.centerRight,
child: Padding(
padding: EdgeInsets.only(
right: 12,
top: MediaQuery.of(context).padding.top + 48,
),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.black38,
borderRadius: BorderRadius.circular(6),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: shortcuts.map((s) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 1.5),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 42,
child: Text(
s.$1,
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
),
),
Text(
s.$2,
style: const TextStyle(color: Colors.white70, fontSize: 10),
),
],
),
);
}).toList(),
),
),
),
);
}
}