Files
MookNote/lib/pages/online_search/movie_detail_page.dart
DelLevin-Home f64fa16756 1
2026-08-14 19:24:18 +08:00

1774 lines
64 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:http/http.dart' as http;
import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart';
import 'package:path/path.dart' as p;
import 'package:provider/provider.dart';
import 'package:uuid/uuid.dart';
import '../../utils/server_config.dart';
import '../../utils/user_prefs.dart';
import '../../models/data_models.dart';
import '../../providers/app_provider.dart';
import '../../utils/image_path_helper.dart';
import '../../utils/toast_util.dart';
import '../../widgets/app_overlay.dart';
/// 影视详情页 - 在线版
class MovieDetailPage extends StatefulWidget {
final int vodId;
const MovieDetailPage({super.key, required this.vodId});
@override
State<MovieDetailPage> createState() => _MovieDetailPageState();
}
class _MovieDetailPageState extends State<MovieDetailPage>
with WidgetsBindingObserver {
Map<String, dynamic>? _data;
List<Map<String, dynamic>> _staffList = [];
bool _loading = true;
bool _staffLoading = false;
String? _error;
bool _expanded = false;
Movie? _localMovie;
int _currentTab = 0;
int _detailStyle = 0; // 0: 紧凑, 1: 沉浸式
// 播放 Tab 数据
bool _playLoading = false;
String? _playError;
List<String> _playSources = [];
List<List<({String name, String url})>> _playEpisodes = [];
Set<String> _playedEpisodes = {};
int _currentSource = 0;
int? _currentEpisode;
// 播放器状态
bool _isPlaying = false;
Player? _player;
VideoController? _videoController;
bool _playerCreating = false;
bool _isFullscreen = false;
// 播放器实时状态
Duration _position = Duration.zero;
Duration _duration = Duration.zero;
bool _isPlayerPlaying = false;
bool _isBuffering = false;
double _rate = 1.0;
bool _showControls = true;
bool _isSeeking = false;
bool _isLongPress2x = false;
final List<StreamSubscription> _playerSubs = [];
Timer? _hideControlsTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_load();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_hideControlsTimer?.cancel();
for (final sub in _playerSubs) {
sub.cancel();
}
_playerSubs.clear();
_player?.dispose();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
_player?.pause();
}
}
Future<void> _load() async {
final token = UserPrefs().movieSearchToken;
try {
final url =
'${ServerConfig.vipBaseUrl}/api/movie/detail?vodId=${widget.vodId}&token=$token';
final resp =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10));
if (!mounted) return;
if (resp.statusCode == 200) {
final json_ = json.decode(resp.body);
if (json_['code'] == 0 && json_['data'] != null) {
setState(() {
_data = json_['data'];
_loading = false;
});
_loadStaff();
_checkLocal();
return;
}
}
setState(() {
_error = '加载失败';
_loading = false;
});
} catch (_) {
if (mounted)
setState(() {
_error = '网络错误';
_loading = false;
});
}
}
Future<void> _loadStaff() async {
final staffStr = _data?['vod_staff'] ?? '';
if (staffStr.toString().isEmpty) return;
final token = UserPrefs().movieSearchToken;
setState(() {
_staffLoading = true;
});
try {
final url = '${ServerConfig.vipBaseUrl}/api/actor/staff-pic?token=$token';
final resp = await http.post(Uri.parse(url),
body: staffStr.toString(),
headers: {
'Content-Type': 'application/json'
}).timeout(const Duration(seconds: 10));
if (!mounted) return;
if (resp.statusCode == 200) {
final json_ = json.decode(resp.body);
if (json_['code'] == 0 && json_['data'] != null) {
setState(() {
_staffList = (json_['data'] as List)
.map((e) => e as Map<String, dynamic>)
.toList();
_staffLoading = false;
});
return;
}
}
} catch (_) {}
if (mounted)
setState(() {
_staffLoading = false;
});
}
Future<void> _loadPlayInfo() async {
if (_playSources.isNotEmpty || _playLoading) return;
final token = UserPrefs().movieSearchToken;
setState(() {
_playLoading = true;
_playError = null;
});
try {
final url =
'${ServerConfig.vipBaseUrl}/api/movie/detail/plus?vodId=${widget.vodId}&token=$token';
final resp =
await http.get(Uri.parse(url)).timeout(const Duration(seconds: 10));
if (!mounted) return;
if (resp.statusCode == 200) {
final json_ = json.decode(resp.body);
if (json_['code'] == 0 && json_['data'] != null) {
final data = json_['data'] as Map<String, dynamic>;
final sources = (data['vod_play_from'] ?? '')
.toString()
.split(r'$$$')
.where((s) => s.trim().isNotEmpty)
.toList();
final urlGroups = (data['vod_play_url'] ?? '')
.toString()
.split(r'$$$');
final episodes = <List<({String name, String url})>>[];
for (var i = 0; i < sources.length; i++) {
final group =
i < urlGroups.length ? urlGroups[i] : '';
final eps = <({String name, String url})>[];
for (final raw in group.split('#')) {
final parts = raw.split('\$');
if (parts.length >= 2 && parts[1].trim().isNotEmpty) {
eps.add((name: parts[0].trim(), url: parts[1].trim()));
}
}
episodes.add(eps);
}
if (mounted) {
setState(() {
_playSources = sources;
_playEpisodes = episodes;
_playedEpisodes = UserPrefs().getPlayedEpisodes(widget.vodId).toSet();
_playLoading = false;
});
}
return;
}
}
if (mounted) {
setState(() {
_playError = '加载失败';
_playLoading = false;
});
}
} catch (_) {
if (mounted) {
setState(() {
_playError = '网络错误';
_playLoading = false;
});
}
}
}
Future<void> _startPlay(String url, int episodeIndex) async {
if (_playerCreating) return;
setState(() {
_playerCreating = true;
});
try {
if (_player == null) {
_player = Player();
_videoController = VideoController(_player!);
_subscribePlayerStreams();
}
await _player!.open(Media(url));
// 切集后恢复倍速
if (_rate != 1.0) {
await _player!.setRate(_rate);
}
if (!mounted) return;
final epName = _playEpisodes[_currentSource][episodeIndex].name;
await UserPrefs().addPlayedEpisode(widget.vodId, epName);
setState(() {
_isPlaying = true;
_currentEpisode = episodeIndex;
_playedEpisodes.add(epName);
_playerCreating = false;
_position = Duration.zero;
_duration = Duration.zero;
_showControls = true;
_isLongPress2x = false;
});
_restartHideControlsTimer();
} catch (_) {
if (mounted) {
ToastUtil.show(context, '播放失败');
setState(() {
_playerCreating = false;
});
}
}
}
void _subscribePlayerStreams() {
final p = _player!;
_playerSubs.add(p.stream.playing.listen((v) {
if (mounted) setState(() => _isPlayerPlaying = v);
}));
_playerSubs.add(p.stream.position.listen((v) {
if (mounted && !_isSeeking) setState(() => _position = v);
}));
_playerSubs.add(p.stream.duration.listen((v) {
if (mounted) setState(() => _duration = v);
}));
_playerSubs.add(p.stream.buffering.listen((v) {
if (mounted) setState(() => _isBuffering = v);
}));
_playerSubs.add(p.stream.rate.listen((v) {
if (mounted) setState(() => _rate = v);
}));
_playerSubs.add(p.stream.completed.listen((completed) {
if (completed && mounted) _onPlayCompleted();
}));
}
void _onPlayCompleted() {
// 自动播放下一集
final episodes = _playEpisodes[_currentSource];
if (_currentEpisode == null) return;
if (_currentEpisode! < episodes.length - 1) {
_startPlay(episodes[_currentEpisode! + 1].url, _currentEpisode! + 1);
} else {
// 末集,退出播放
setState(() {
_isPlaying = false;
_isPlayerPlaying = false;
});
}
}
void _playPrevEpisode() {
if (_currentEpisode == null || _currentEpisode! <= 0) return;
final episodes = _playEpisodes[_currentSource];
_startPlay(episodes[_currentEpisode! - 1].url, _currentEpisode! - 1);
}
void _playNextEpisode() {
if (_currentEpisode == null) return;
final episodes = _playEpisodes[_currentSource];
if (_currentEpisode! >= episodes.length - 1) return;
_startPlay(episodes[_currentEpisode! + 1].url, _currentEpisode! + 1);
}
Future<void> _showRateSheet() async {
final colors = Theme.of(context).colorScheme;
final rates = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0];
final selected = await appModalBottomSheet<double>(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2)))),
Text('播放速度',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: rates
.map((r) => InkWell(
onTap: () => Navigator.pop(ctx, r),
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 18, vertical: 10),
decoration: BoxDecoration(
color: _rate == r
? colors.primaryContainer
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Text('${r}x',
style: TextStyle(
fontSize: 14,
fontWeight: _rate == r
? FontWeight.w600
: FontWeight.w400,
color: _rate == r
? colors.onPrimaryContainer
: colors.onSurface)),
),
))
.toList(),
),
]),
),
),
);
if (selected != null && _player != null) {
await _player!.setRate(selected);
// setRate 会通过 stream 回写 _rate但同步设置避免延迟
if (mounted) setState(() => _rate = selected);
}
}
void _toggleControls() {
setState(() => _showControls = !_showControls);
if (_showControls) {
_restartHideControlsTimer();
} else {
_hideControlsTimer?.cancel();
}
}
void _restartHideControlsTimer() {
_hideControlsTimer?.cancel();
_hideControlsTimer = Timer(const Duration(seconds: 4), () {
if (mounted && _isPlayerPlaying && !_isSeeking) {
setState(() => _showControls = false);
}
});
}
String _fmtDuration(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';
}
void _closePlayer() {
_player?.pause();
setState(() {
_isPlaying = false;
});
}
Future<void> _enterFullscreen() async {
await SystemChrome.setPreferredOrientations([
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
setState(() {
_isFullscreen = true;
});
}
Future<void> _exitFullscreen() async {
await SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
setState(() {
_isFullscreen = false;
});
}
void _checkLocal() {
final name = _data?['vod_name'] ?? '';
if (name.toString().isEmpty) return;
final provider = context.read<AppProvider>();
final match =
provider.movies.where((m) => !m.isDeleted && m.title == name).toList();
if (match.isNotEmpty) {
setState(() {
_localMovie = match.first;
});
}
}
Future<void> _addMovie(String status) async {
final m = _data!;
final name = m['vod_name'] ?? '';
final director = m['vod_director'] ?? '';
final actorStr = m['vod_actor'] ?? '';
final classStr = m['vod_class'] ?? '';
final yearStr = m['vod_year'] ?? '';
final scoreStr = m['vod_score'] ?? '';
final content = m['vod_content'] ?? m['vod_blurb'] ?? '';
final pic = m['vod_pic'] ?? '';
DateTime? releaseDate;
final pubdateStr = m['vod_pubdate'] ?? '';
final dateSource =
pubdateStr.toString().isNotEmpty ? pubdateStr : yearStr;
if (dateSource.toString().isNotEmpty) {
// 提取 yyyy-MM-dd 部分,兼容 "2025-09-08(多伦多电影节)" 等格式
final match =
RegExp(r'(\d{4}-\d{2}-\d{2})').firstMatch(dateSource.toString());
if (match != null) {
releaseDate = DateTime.tryParse(match.group(1)!);
} else {
// 纯年份如 "2025"
final yearMatch =
RegExp(r'^(\d{4})$').firstMatch(dateSource.toString());
if (yearMatch != null) {
releaseDate = DateTime.tryParse('${yearMatch.group(1)}-01-01');
}
}
}
final movieId = const Uuid().v4();
String? posterPath;
if (pic.toString().isNotEmpty) {
try {
final resp = await http.get(Uri.parse(pic.toString()), headers: {
'User-Agent': 'Mozilla/5.0'
}).timeout(const Duration(seconds: 15));
if (resp.statusCode == 200 &&
resp.bodyBytes.length < 10 * 1024 * 1024) {
final fileName =
'poster_${DateTime.now().millisecondsSinceEpoch}.jpg';
final targetPath = await ImagePathHelper.instance
.getMoviePosterPath(movieId, fileName);
await ImagePathHelper.instance.ensureDirExists(p.dirname(targetPath));
await File(targetPath).writeAsBytes(resp.bodyBytes);
posterPath = targetPath;
}
} catch (_) {}
}
final movie = Movie(
id: movieId,
title: name.toString(),
posterPath: posterPath,
releaseDate: releaseDate,
directors: _splitStr(director.toString()),
actors: _splitStr(actorStr.toString()),
genres: _splitStr(classStr.toString()),
summary: content.toString(),
rating: double.tryParse(scoreStr.toString()),
status: status,
createdAt: DateTime.now(),
updatedAt: DateTime.now(),
);
if (!mounted) return;
final provider = context.read<AppProvider>();
await provider.addMovie(movie);
await provider.loadMovies();
if (mounted) {
setState(() {
_localMovie = provider.movies.firstWhere((m) => m.id == movie.id);
});
ToastUtil.show(context, '已添加到${_statusLabel(status)}');
}
}
List<String> _splitStr(String s) => s
.split(RegExp(r'[,/、]'))
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
String _statusLabel(String status) {
switch (status) {
case 'watched':
return '已看';
case 'watching':
return '在看';
case 'want_to_watch':
return '想看';
default:
return '';
}
}
void _showAddSheet() {
final colors = Theme.of(context).colorScheme;
appModalBottomSheet(
context: context,
backgroundColor: colors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 6, 16, 16),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Center(
child: Container(
width: 36,
height: 4,
margin: const EdgeInsets.only(bottom: 14),
decoration: BoxDecoration(
color: colors.onSurface.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(2)))),
Text('添加到',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: colors.onSurface)),
const SizedBox(height: 14),
_sheetItem(
ctx, colors, Icons.check_circle_outline, '已看', 'watched'),
_sheetItem(
ctx, colors, Icons.play_circle_outline, '在看', 'watching'),
_sheetItem(
ctx, colors, Icons.bookmark_outline, '想看', 'want_to_watch'),
]),
),
),
);
}
Widget _sheetItem(BuildContext ctx, ColorScheme colors, IconData icon,
String label, String status) {
return InkWell(
onTap: () {
Navigator.pop(ctx);
_addMovie(status);
},
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
margin: const EdgeInsets.only(bottom: 4),
child: Row(children: [
Icon(icon, size: 22, color: colors.onSurface.withValues(alpha: 0.6)),
const SizedBox(width: 12),
Text(label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
]),
),
);
}
// ── 播放器区域 ──────────────────────────────────────────
Widget _buildPlayerArea(ColorScheme colors) {
return Column(children: [
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh, shape: BoxShape.circle),
child: Icon(Icons.arrow_back,
size: 20, color: colors.onSurface),
),
),
const Spacer(),
GestureDetector(
onTap: _closePlayer,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
borderRadius: BorderRadius.circular(18)),
child: Text('关闭播放器',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: colors.onSurface)),
),
),
]),
),
),
AspectRatio(
aspectRatio: 16 / 9,
child: _buildVideoStack(colors, fullscreen: false),
),
]);
}
Widget _buildFullscreenPlayer(ColorScheme colors) {
return Scaffold(
backgroundColor: Colors.black,
body: _buildVideoStack(colors, fullscreen: true),
);
}
/// 自定义控件的视频区域:底层 Video + 叠加控件层
Widget _buildVideoStack(ColorScheme colors, {required bool fullscreen}) {
final hasController = _videoController != null;
final episodes = _playSources.isNotEmpty ? _playEpisodes[_currentSource] : <({String name, String url})>[];
final epName = (_currentEpisode != null && _currentEpisode! < episodes.length)
? episodes[_currentEpisode!].name
: '';
final hasPrev = _currentEpisode != null && _currentEpisode! > 0;
final hasNext = _currentEpisode != null && _currentEpisode! < episodes.length - 1;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: _toggleControls,
onLongPressStart: fullscreen ? (_) => _startLongPress2x() : null,
onLongPressEnd: fullscreen ? (_) => _stopLongPress2x() : null,
child: Stack(
fit: StackFit.expand,
children: [
// 视频画面
if (hasController)
Video(
controller: _videoController!,
controls: null,
fit: BoxFit.contain,
onEnterFullscreen: _enterFullscreen,
onExitFullscreen: _exitFullscreen,
)
else
Container(color: Colors.black),
// 缓冲指示
if (_isBuffering && hasController)
const Center(
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
),
// 中央播放/暂停按钮(暂停且非缓冲时)
if (!_isPlayerPlaying && !_isBuffering && hasController)
Center(
child: GestureDetector(
onTap: () => _player?.play(),
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.5),
shape: BoxShape.circle,
),
child: const Icon(Icons.play_arrow,
color: Colors.white, size: 36),
),
),
),
// 长按 2x 提示
if (_isLongPress2x)
Center(
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(8),
),
child: const Text('2x',
style: TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600)),
),
),
// 控件层
if (_showControls) ...[
// 顶部渐变 + 标题
Positioned(
top: 0,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.fromLTRB(
fullscreen ? 24 : 8, fullscreen ? 24 : 8, 8, 8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.5),
Colors.transparent,
],
),
),
child: Row(children: [
if (fullscreen)
GestureDetector(
onTap: _exitFullscreen,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.arrow_back,
color: Colors.white, size: 22),
),
),
if (epName.isNotEmpty) ...[
const SizedBox(width: 8),
Expanded(
child: Text(epName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500)),
),
],
const Spacer(),
if (!fullscreen)
GestureDetector(
onTap: _enterFullscreen,
child: const Padding(
padding: EdgeInsets.all(4),
child: Icon(Icons.fullscreen,
color: Colors.white, size: 22),
),
),
]),
),
),
// 底部渐变 + 进度条 + 按钮
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
padding: EdgeInsets.fromLTRB(
8, 8, 8, fullscreen ? 16 : 8),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [
Colors.black.withValues(alpha: 0.5),
Colors.transparent,
],
),
),
child: Column(mainAxisSize: MainAxisSize.min, children: [
// 进度条
Row(children: [
Text(_fmtDuration(_position),
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontFeatures: [FontFeature.tabularFigures()])),
Expanded(
child: SliderTheme(
data: SliderThemeData(
trackHeight: 2,
thumbShape:
const RoundSliderThumbShape(enabledThumbRadius: 6),
overlayShape:
const RoundSliderOverlayShape(overlayRadius: 12),
trackShape: const RectangularSliderTrackShape(),
activeTrackColor: Colors.white,
inactiveTrackColor:
Colors.white.withValues(alpha: 0.3),
thumbColor: Colors.white,
),
child: Slider(
value: _position.inMilliseconds.toDouble().clamp(
0,
_duration.inMilliseconds.toDouble() > 0
? _duration.inMilliseconds.toDouble()
: 1),
min: 0,
max: _duration.inMilliseconds.toDouble() > 0
? _duration.inMilliseconds.toDouble()
: 1,
onChangeStart: (_) {
setState(() => _isSeeking = true);
_hideControlsTimer?.cancel();
},
onChanged: (v) {
setState(() {
_position =
Duration(milliseconds: v.round());
});
},
onChangeEnd: (v) async {
await _player
?.seek(Duration(milliseconds: v.round()));
setState(() => _isSeeking = false);
_restartHideControlsTimer();
},
),
),
),
Text(_fmtDuration(_duration),
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 11,
fontFeatures: const [
FontFeature.tabularFigures()
])),
]),
// 按钮行
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 上一集
IconButton(
onPressed: hasPrev ? _playPrevEpisode : null,
icon: Icon(Icons.skip_previous,
color: hasPrev
? Colors.white
: Colors.white.withValues(alpha: 0.3)),
iconSize: 22,
padding: const EdgeInsets.symmetric(horizontal: 8),
constraints: const BoxConstraints(
minWidth: 36, minHeight: 36),
),
// 播放/暂停
GestureDetector(
onTap: () {
if (_isPlayerPlaying) {
_player?.pause();
} else {
_player?.play();
}
_restartHideControlsTimer();
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Icon(
_isPlayerPlaying
? Icons.pause_rounded
: Icons.play_arrow_rounded,
color: Colors.white,
size: 32),
),
),
// 下一集
IconButton(
onPressed: hasNext ? _playNextEpisode : null,
icon: Icon(Icons.skip_next,
color: hasNext
? Colors.white
: Colors.white.withValues(alpha: 0.3)),
iconSize: 22,
padding: const EdgeInsets.symmetric(horizontal: 8),
constraints: const BoxConstraints(
minWidth: 36, minHeight: 36),
),
const SizedBox(width: 16),
// 倍速
GestureDetector(
onTap: _showRateSheet,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
_rate == 1.0 ? '倍速' : '${_rate}x',
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w500),
),
),
),
],
),
]),
),
),
],
],
),
);
}
void _startLongPress2x() {
if (!_isPlayerPlaying) return;
_player?.setRate(2.0);
setState(() => _isLongPress2x = true);
_hideControlsTimer?.cancel();
}
void _stopLongPress2x() {
_player?.setRate(_rate);
setState(() => _isLongPress2x = false);
_restartHideControlsTimer();
}
// ── Build ──────────────────────────────────────────────
@override
Widget build(BuildContext context) {
final colors = Theme.of(context).colorScheme;
if (_isFullscreen && _isPlaying) return _buildFullscreenPlayer(colors);
return Scaffold(
backgroundColor: colors.surface,
floatingActionButton:
(!_loading && _error == null && _localMovie == null && _data != null && _currentTab != 2)
? FloatingActionButton(
onPressed: _showAddSheet,
backgroundColor: colors.primary,
child: Icon(Icons.add, color: colors.onPrimary))
: null,
body: _loading
? Center(
child: CircularProgressIndicator(
color: colors.primary, strokeWidth: 2))
: _error != null
? _buildError(colors)
: _buildBody(colors),
);
}
Widget _buildError(ColorScheme colors) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline,
size: 48, color: colors.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 16),
Text(_error!,
style: TextStyle(
fontSize: 14, color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 16),
TextButton(
onPressed: () {
setState(() {
_loading = true;
_error = null;
});
_load();
},
child: Text('重试', style: TextStyle(color: colors.primary))),
],
));
}
Widget _buildBody(ColorScheme colors) {
if (_detailStyle == 1) return _buildImmersiveBody(colors);
final m = _data!;
final pic = m['vod_pic'] ?? '';
final name = m['vod_name'] ?? '';
final isEnd = m['vod_isend'] ?? 0;
final year = m['vod_year'] ?? '';
final area = m['vod_area'] ?? '';
final typeName = m['type_name'] ?? '';
final classStr = m['vod_class'] ?? '';
final score = m['vod_score'] ?? '';
final metaParts =
[year, area].where((s) => s.toString().isNotEmpty).join(' · ');
final typeParts =
[typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / ');
return Scaffold(
backgroundColor: colors.surface,
body: Column(children: [
// 头部:返回按钮 + 海报信息(播放时替换为播放器区域)
_isPlaying
? _buildPlayerArea(colors)
: SafeArea(
bottom: false,
child: Column(children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
shape: BoxShape.circle),
child: Icon(Icons.arrow_back,
size: 20, color: colors.onSurface)),
),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _detailStyle = _detailStyle == 0 ? 1 : 0),
child: Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: colors.surfaceContainerHigh,
shape: BoxShape.circle),
child: Icon(
_detailStyle == 0
? Icons.crop_landscape_rounded
: Icons.grid_view_rounded,
size: 18,
color: colors.onSurface)),
),
]),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 16),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
width: 120,
height: 170,
child: pic.toString().isNotEmpty
? Image.network(pic,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_posterPlaceholder(colors))
: _posterPlaceholder(colors),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(name,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: colors.onSurface)),
const SizedBox(height: 8),
if (score.toString().isNotEmpty && score != '0.0') ...[
Row(children: [
Icon(Icons.star_rounded, size: 16, color: const Color(0xFFF59E0B)),
const SizedBox(width: 3),
Text('$score', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: colors.onSurface)),
Text(' /10', style: TextStyle(fontSize: 11, color: colors.onSurface.withValues(alpha: 0.3))),
]),
const SizedBox(height: 2),
Text('评分来源于网络资源收集,并非官方评分', style: TextStyle(fontSize: 10, color: colors.onSurface.withValues(alpha: 0.25))),
const SizedBox(height: 8),
],
_endTag(isEnd),
if (metaParts.isNotEmpty) ...[
const SizedBox(height: 8),
Text(metaParts,
style: TextStyle(
fontSize: 12,
color: colors.onSurface
.withValues(alpha: 0.5))),
],
if (typeParts.isNotEmpty) ...[
const SizedBox(height: 3),
Text(typeParts,
style: TextStyle(
fontSize: 11,
color: colors.onSurface
.withValues(alpha: 0.4)),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
if (_localMovie != null) ...[
const SizedBox(height: 10),
_buildLocalStatus(colors),
],
]),
),
]),
),
]),
),
// Tab 栏
Container(
decoration: BoxDecoration(
color: colors.surface,
border: Border(
bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
child: Row(children: [
_buildTabButton('概要', 0),
_buildTabButton('演职人员', 1),
if (UserPrefs().playbackUnlocked) _buildTabButton('在线播放', 2),
]),
),
// Tab 内容
Expanded(
child: _currentTab == 0
? _buildOverview(colors)
: _currentTab == 1
? _buildStaffTab(colors)
: _buildPlayTab(colors),
),
]),
);
}
// ── 沉浸式布局 ──────────────────────────────────────────
Widget _buildImmersiveBody(ColorScheme colors) {
final m = _data!;
final pic = m['vod_pic'] ?? '';
final name = m['vod_name'] ?? '';
final isEnd = m['vod_isend'] ?? 0;
final year = m['vod_year'] ?? '';
final area = m['vod_area'] ?? '';
final typeName = m['type_name'] ?? '';
final classStr = m['vod_class'] ?? '';
final score = m['vod_score'] ?? '';
final metaParts = [year, area].where((s) => s.toString().isNotEmpty).join(' · ');
final typeParts = [typeName, classStr].where((s) => s.toString().isNotEmpty).join(' / ');
return Scaffold(
backgroundColor: colors.surface,
body: Column(children: [
// 沉浸式头部(播放时替换为播放器区域)
_isPlaying
? _buildPlayerArea(colors)
: Stack(children: [
SizedBox(
width: double.infinity,
height: 320,
child: pic.toString().isNotEmpty
? Image.network(pic, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Container(color: colors.surfaceContainerHighest))
: Container(color: colors.surfaceContainerHighest,
child: Icon(Icons.movie_outlined, size: 64, color: colors.onSurface.withValues(alpha: 0.1))),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black.withValues(alpha: 0.8)],
stops: const [0.35, 1.0],
),
),
),
),
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Row(children: [
GestureDetector(
onTap: () => Navigator.pop(context),
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle),
child: const Icon(Icons.arrow_back, size: 20, color: Colors.white)),
),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _detailStyle = 0),
child: Container(
width: 36, height: 36,
decoration: BoxDecoration(color: Colors.black.withValues(alpha: 0.3), shape: BoxShape.circle),
child: const Icon(Icons.grid_view_rounded, size: 18, color: Colors.white)),
),
]),
),
),
Positioned(
left: 16, right: 16, bottom: 18,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [
Text(name, maxLines: 2, overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: Colors.white)),
const SizedBox(height: 8),
Row(children: [
if (score.toString().isNotEmpty && score != '0.0') ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.star_rounded, size: 18, color: Colors.amber.shade400),
const SizedBox(width: 3),
Text('$score', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Colors.white)),
],
),
),
const SizedBox(width: 10),
],
_endTag(isEnd),
if (metaParts.isNotEmpty) ...[
const SizedBox(width: 8),
Text(metaParts, style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.7))),
],
]),
if (typeParts.isNotEmpty) ...[
const SizedBox(height: 4),
Text(typeParts, maxLines: 1, overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 12, color: Colors.white.withValues(alpha: 0.5))),
],
if (_localMovie != null) ...[
const SizedBox(height: 8),
_buildLocalStatus(colors),
],
]),
),
]),
// Tab 栏
Container(
decoration: BoxDecoration(
color: colors.surface,
border: Border(bottom: BorderSide(color: colors.outlineVariant, width: 0.5))),
child: Row(children: [
_buildTabButton('概要', 0),
_buildTabButton('演职人员', 1),
if (UserPrefs().playbackUnlocked) _buildTabButton('在线播放', 2),
]),
),
// Tab 内容
Expanded(
child: _currentTab == 0
? _buildOverview(colors)
: _currentTab == 1
? _buildStaffTab(colors)
: _buildPlayTab(colors),
),
]),
);
}
Widget _posterPlaceholder(ColorScheme colors) {
return Container(
color: colors.surfaceContainerHighest,
child: Center(
child: Icon(Icons.movie_outlined,
size: 32, color: colors.onSurface.withValues(alpha: 0.15))));
}
Widget _endTag(int isEnd) {
final finished = isEnd == 1;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: finished
? const Color(0xFF16A34A).withValues(alpha: 0.1)
: const Color(0xFFF59E0B).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(6),
),
child: Text(finished ? '已完结' : '连载中',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: finished
? const Color(0xFF16A34A)
: const Color(0xFFF59E0B))),
);
}
Widget _buildLocalStatus(ColorScheme colors) {
final status = _localMovie!.status;
final label = _statusLabel(status);
Color dotColor;
switch (status) {
case 'watched':
dotColor = colors.primary;
break;
case 'watching':
dotColor = const Color(0xFF666666);
break;
default:
dotColor = const Color(0xFF999999);
break;
}
return Row(children: [
Container(
width: 6,
height: 6,
decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle)),
const SizedBox(width: 6),
Text('已在本地 · $label',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: colors.onSurface.withValues(alpha: 0.6))),
]);
}
Widget _buildTabButton(String label, int index) {
final colors = Theme.of(context).colorScheme;
final selected = _currentTab == index;
return GestureDetector(
onTap: () {
setState(() => _currentTab = index);
if (index == 2 &&
UserPrefs().playbackUnlocked &&
_playSources.isEmpty &&
!_playLoading) {
_loadPlayInfo();
}
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 11),
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: selected ? colors.primary : Colors.transparent,
width: 2))),
child: Text(label,
style: TextStyle(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected
? colors.primary
: colors.onSurface.withValues(alpha: 0.4))),
),
);
}
// ── 概要 Tab ──────────────────────────────────────────
Widget _buildOverview(ColorScheme colors) {
final m = _data!;
final isManual = m['is_manual_optimized'] ?? 0;
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
children: [
// 信息行
_infoRow(colors, '导演', m['vod_director']),
_infoRow(colors, '主演', _formatActors()),
_infoRow(colors, '语言', m['vod_lang']),
_infoRow(colors, '时长', m['vod_duration']),
_infoRow(colors, '上映', m['vod_pubdate']),
const SizedBox(height: 16),
// 标签
if (isManual == 1 || (m['vod_tag'] ?? '').toString().isNotEmpty) ...[
_buildTags(colors),
const SizedBox(height: 16),
],
// 分隔线
Container(height: 0.5, color: colors.outlineVariant),
const SizedBox(height: 16),
// 简介
_buildSynopsis(colors),
],
);
}
Widget _infoRow(ColorScheme colors, String label, dynamic value) {
final text = value?.toString() ?? '';
if (text.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.symmetric(vertical: 5),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
SizedBox(
width: 44,
child: Text(label,
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.4)))),
const SizedBox(width: 8),
Expanded(
child: Text(text,
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.75),
height: 1.5))),
]),
);
}
String _formatActors() {
final staffStr = _data?['vod_staff'] ?? '';
if (staffStr.toString().isNotEmpty) {
try {
final staff = json.decode(staffStr.toString()) as List;
final actors = staff.where((s) => s['position'] == '演员').toList();
if (actors.isNotEmpty) {
return actors.map((s) {
final name = s['name'] ?? '';
final role = s['role'] ?? '';
if (role.toString().isNotEmpty) return '$name$role';
return name;
}).join('');
}
} catch (_) {}
}
return _data?['vod_actor'] ?? '';
}
Widget _buildTags(ColorScheme colors) {
final m = _data!;
final isManual = m['is_manual_optimized'] ?? 0;
final tagStr = m['vod_tag'] ?? '';
final tags =
tagStr.toString().split(',').where((t) => t.trim().isNotEmpty).toList();
return Wrap(
spacing: 6,
runSpacing: 6,
children: [
if (isManual == 1)
_tag('官方优化', const Color(0xFF16A34A), highlight: true),
...tags.map(
(t) => _tag(t.trim(), colors.onSurface.withValues(alpha: 0.5))),
],
);
}
Widget _tag(String text, Color color, {bool highlight = false}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: highlight
? const Color(0xFF16A34A).withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(6),
border: Border.all(
color: highlight
? const Color(0xFF16A34A).withValues(alpha: 0.3)
: color.withValues(alpha: 0.2),
width: 0.5),
),
child: Text(text,
style: TextStyle(
fontSize: 11,
color: highlight ? const Color(0xFF16A34A) : color)),
);
}
Widget _buildSynopsis(ColorScheme colors) {
final m = _data!;
final blurb = m['vod_blurb'] ?? '';
final content = m['vod_content'] ?? '';
final fullText = content.toString().isNotEmpty
? content.toString().replaceAll(RegExp(r'<[^>]*>'), '')
: blurb.toString();
final isLong = fullText.length > 120;
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
AnimatedCrossFade(
duration: const Duration(milliseconds: 200),
crossFadeState:
_expanded ? CrossFadeState.showSecond : CrossFadeState.showFirst,
firstChild: Text(fullText,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.7),
height: 1.8),
maxLines: 4,
overflow: TextOverflow.ellipsis),
secondChild: Text(fullText,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.7),
height: 1.8)),
),
if (isLong)
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_expanded ? '收起' : '展开全文',
style: TextStyle(
fontSize: 12,
color: colors.primary,
fontWeight: FontWeight.w500)),
),
),
]);
}
// ── 播放 Tab ──────────────────────────────────────────
Widget _buildPlayTab(ColorScheme colors) {
if (!UserPrefs().playbackUnlocked) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.lock_outline,
size: 40, color: colors.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text('播放功能未解锁',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: colors.onSurface.withValues(alpha: 0.5))),
const SizedBox(height: 6),
Text('请在增强搜索中输入验证码以解锁7天有效',
style: TextStyle(
fontSize: 12,
color: colors.onSurface.withValues(alpha: 0.35))),
],
),
),
);
}
if (_playLoading) {
return Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: colors.primary,
),
),
);
}
if (_playError != null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline,
size: 40, color: colors.onSurface.withValues(alpha: 0.2)),
const SizedBox(height: 12),
Text(_playError!,
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.4))),
const SizedBox(height: 12),
TextButton(
onPressed: _loadPlayInfo,
child: Text('重试', style: TextStyle(color: colors.primary)),
),
],
),
);
}
if (_playSources.isEmpty || _playEpisodes.isEmpty) {
return Center(
child: Text('暂无播放资源',
style: TextStyle(
fontSize: 13, color: colors.onSurface.withValues(alpha: 0.35))),
);
}
final episodes = _playEpisodes[_currentSource];
return Column(children: [
// 播放源选择器
SizedBox(
height: 44,
child: ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
itemCount: _playSources.length,
itemBuilder: (_, i) {
final selected = _currentSource == i;
return GestureDetector(
onTap: () {
setState(() {
_currentSource = i;
_currentEpisode = null;
});
},
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
margin: const EdgeInsets.only(right: 8),
decoration: BoxDecoration(
color: selected
? colors.primaryContainer
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(_playSources[i],
style: TextStyle(
fontSize: 12,
fontWeight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected
? colors.onPrimaryContainer
: colors.onSurface.withValues(alpha: 0.6),
)),
),
);
},
),
),
const SizedBox(height: 8),
// 剧集网格
Expanded(
child: GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 40),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
childAspectRatio: 1.4,
),
itemCount: episodes.length,
itemBuilder: (_, i) {
final ep = episodes[i];
final isCurrent = _currentEpisode == i && _isPlaying;
final isPlayed = _playedEpisodes.contains(ep.name);
return GestureDetector(
onTap: () => _startPlay(ep.url, i),
child: Container(
decoration: BoxDecoration(
color: isCurrent
? colors.primary
: isPlayed
? colors.surfaceContainerHigh
: colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(ep.name,
style: TextStyle(
fontSize: 12,
fontWeight: isCurrent ? FontWeight.w600 : FontWeight.w400,
color: isCurrent
? colors.onPrimary
: isPlayed
? colors.onSurface.withValues(alpha: 0.5)
: colors.onSurface,
),
maxLines: 1,
overflow: TextOverflow.ellipsis),
),
);
},
),
),
]);
}
// ── 演职人员 Tab ──────────────────────────────────────────
Widget _buildStaffTab(ColorScheme colors) {
if (_staffLoading)
return Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2, color: colors.primary)));
if (_staffList.isEmpty)
return Center(
child: Text('暂无演职信息',
style: TextStyle(
fontSize: 13,
color: colors.onSurface.withValues(alpha: 0.35))));
return GridView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 40),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 10,
mainAxisSpacing: 14,
childAspectRatio: 0.7),
itemCount: _staffList.length,
itemBuilder: (context, index) =>
_buildStaffCard(colors, _staffList[index]),
);
}
Widget _buildStaffCard(ColorScheme colors, Map<String, dynamic> s) {
final name = s['name'] ?? '';
final position = s['position'] ?? '';
final role = s['role'] ?? '';
final pic = s['actor_pic'] ?? '';
final sub = [position, if (role.toString().isNotEmpty) role].join(' · ');
return Column(children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: double.infinity,
height: 100,
child: pic.toString().isNotEmpty
? Image.network(pic,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
_avatarPlaceholder(colors, name))
: _avatarPlaceholder(colors, name),
),
),
const SizedBox(height: 6),
Text(name,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: colors.onSurface),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center),
const SizedBox(height: 2),
Text(sub,
style: TextStyle(
fontSize: 9, color: colors.onSurface.withValues(alpha: 0.4)),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center),
]);
}
Widget _avatarPlaceholder(ColorScheme colors, String name) {
final ch = name.isNotEmpty ? name.characters.first : '?';
return Container(
color: colors.surfaceContainerHighest,
child: Center(
child: Text(ch,
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
color: colors.onSurface.withValues(alpha: 0.25)))),
);
}
}