二次开发源码提交
This commit is contained in:
@@ -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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user