diff --git a/app/lib/core/config/app_config.dart b/app/lib/core/config/app_config.dart index 7b01fba..20fe131 100644 --- a/app/lib/core/config/app_config.dart +++ b/app/lib/core/config/app_config.dart @@ -4,8 +4,8 @@ class AppConfig { /// App 版本(对应 pubspec version 的 name 部分;version_code 对应 build 号) /// ⚠️ 每次发版必须与 pubspec.yaml 的 version 同步递增,否则老用户会一直提示更新 - static const String appVersion = '1.1.8'; - static const int appVersionCode = 10; + static const String appVersion = '1.2.0'; + static const int appVersionCode = 12; /// 平台标识(运行时按 iOS/Android 判定,见 platformName) static const String platformNameIOS = 'ios'; diff --git a/app/lib/screens/feed/widgets/feed_video_page.dart b/app/lib/screens/feed/widgets/feed_video_page.dart index 6554c9f..eb2b4be 100644 --- a/app/lib/screens/feed/widgets/feed_video_page.dart +++ b/app/lib/screens/feed/widgets/feed_video_page.dart @@ -287,7 +287,9 @@ class _FeedVideoPageState extends ConsumerState { duration: (ep['duration'] as num?)?.toInt() ?? 0, author: widget.item.author, ); - // 打开 SimplePlayerScreen 播放该集 + // 先暂停 Feed 页播放器,再打开 SimplePlayerScreen + widget.pool.pauseExcept(slotIndex: -1); + // ignore: use_build_context_synchronously context.push('/player', extra: newItem); } diff --git a/app/lib/screens/player/simple_player_screen.dart b/app/lib/screens/player/simple_player_screen.dart index 64ef662..6c5b9de 100644 --- a/app/lib/screens/player/simple_player_screen.dart +++ b/app/lib/screens/player/simple_player_screen.dart @@ -1,54 +1,80 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:media_kit/media_kit.dart'; import 'package:media_kit_video/media_kit_video.dart'; +import 'package:share_plus/share_plus.dart'; import '../../core/config/test_stream.dart'; import '../../core/network/api_exception.dart'; -import '../../core/theme/app_theme.dart'; import '../../core/storage/watch_dedup_store.dart'; +import '../../core/theme/app_theme.dart'; import '../../models/video_item.dart'; +import '../../providers/auth_provider.dart'; import '../../services/video_service.dart'; +import '../feed/widgets/comment_sheet.dart'; import '../feed/widgets/feed_overlays.dart'; +import '../feed/widgets/interaction_bar.dart'; +import '../feed/widgets/video_info_section.dart'; /// 单视频播放页(搜索/用户主页/作品/收藏/历史/系列共用): -/// - 一条极薄进度条贴视频底部,可点击跳转 / 可拖动 seek -/// - 顶部返回 + 倍速 + 系列 -/// - 点屏幕中央切换播放/暂停(图标短暂淡出) -/// - 系列视频支持上下划切换上一集/下一集 -class SimplePlayerScreen extends StatefulWidget { +/// 与 Feed 页体验一致:右侧互动栏、左下信息区、双击点赞、长按倍速/分享、 +/// 抖音风格细进度条、系列视频支持上下划切换集数 +class SimplePlayerScreen extends ConsumerStatefulWidget { const SimplePlayerScreen({super.key, required this.item}); final VideoItem item; @override - State createState() => _SimplePlayerScreenState(); + ConsumerState createState() => + _SimplePlayerScreenState(); } -class _SimplePlayerScreenState extends State { +class _SimplePlayerScreenState extends ConsumerState { late Player _player = Player(); late VideoController _controller = VideoController(_player); Duration _position = Duration.zero; Duration _duration = Duration.zero; bool _playing = true; - double _rate = 1.0; + bool _showPauseIcon = false; + Timer? _pauseIconTimer; bool _seeking = false; + double _seekValue = 0; + // 系列 Map? _seriesData; + List _episodeItems = []; + late PageController _pageController; + int _currentPage = 0; - Timer? _pauseIconTimer; - bool _showPauseIcon = false; + // 当前播放的 item(切集后更新,供互动栏/信息区用) + late VideoItem _currentItem = widget.item; + + // 互动状态(切集后从后端重新拉取) + bool _isLiked = false; + bool _isFavorited = false; + int _likeCount = 0; + int _favoriteCount = 0; + int _commentCount = 0; StreamSubscription? _posSub; StreamSubscription? _durSub; - // 系列切换(上下划) - late PageController _pageController; - int _currentPage = 0; - List _episodeItems = []; + static const _rates = [1.0, 1.5, 2.0, 3.0]; + double _rate = 1.0; + + bool get _hasSeries => + _seriesData != null && (_seriesData!['has_series'] as bool? ?? false); + + List get _episodes => + (_seriesData?['episodes'] as List?) ?? const []; + + String get _seriesTitle => + _seriesData?['series']?['title'] as String? ?? ''; @override void initState() { @@ -56,9 +82,12 @@ class _SimplePlayerScreenState extends State { _open(widget.item); _reportWatch(); _loadSeries(); + _loadInteraction(); _subscribePlayer(); } + // ---------- 播放器 ---------- + void _subscribePlayer() { _posSub = _player.stream.position.listen((p) { if (!mounted || _seeking) return; @@ -74,8 +103,106 @@ class _SimplePlayerScreenState extends State { _player.open(Media(resolvePlayUrl(item)), play: true); } + void _toggle() { + _player.playOrPause(); + setState(() { + _showPauseIcon = !_playing; + _playing = !_playing; + }); + _pauseIconTimer?.cancel(); + _pauseIconTimer = Timer(const Duration(milliseconds: 600), () { + if (mounted) setState(() => _showPauseIcon = false); + }); + } + + // ---------- 倍速 ---------- + + void _setRate(double r) { + _player.setRate(r); + setState(() => _rate = r); + } + + String get _rateLabel => + _rate == _rate.roundToDouble() ? '${_rate.toInt()}X' : '${_rate}X'; + + // ---------- 互动 ---------- + + Future _loadInteraction() async { + // 从后端拉当前视频的点赞/收藏状态 + try { + final data = await VideoService.fetchDetail(_currentItem.videoId); + if (!mounted) return; + setState(() { + _isLiked = (data['is_liked'] as bool?) ?? false; + _isFavorited = (data['is_favorited'] as bool?) ?? false; + _likeCount = (data['like_count'] as num?)?.toInt() ?? 0; + _favoriteCount = (data['favorite_count'] as num?)?.toInt() ?? 0; + _commentCount = (data['comment_count'] as num?)?.toInt() ?? 0; + }); + } catch (_) { + // 游客或未登录时忽略,用默认值 + } + } + + void _onLike() { + if (!ref.read(authProvider).isLoggedIn) { + context.go('/login'); + return; + } + setState(() { + _isLiked = !_isLiked; + _likeCount = _isLiked ? _likeCount + 1 : _likeCount - 1; + }); + VideoService.toggleLike(_currentItem.videoId).catchError((_) { + setState(() { + _isLiked = !_isLiked; + _likeCount = _isLiked ? _likeCount + 1 : _likeCount - 1; + }); + return {}; + }); + } + + void _onFavorite() { + if (!ref.read(authProvider).isLoggedIn) { + context.go('/login'); + return; + } + setState(() { + _isFavorited = !_isFavorited; + _favoriteCount = _isFavorited ? _favoriteCount + 1 : _favoriteCount - 1; + }); + VideoService.toggleFavorite(_currentItem.videoId).catchError((_) { + setState(() { + _isFavorited = !_isFavorited; + _favoriteCount = + _isFavorited ? _favoriteCount + 1 : _favoriteCount - 1; + }); + return {}; + }); + } + + Future _openComments() async { + _player.pause(); + await showCommentSheet( + context, + videoId: _currentItem.videoId, + onCountChanged: (delta) => + setState(() => _commentCount = (_commentCount + delta).clamp(0, 1 << 31)), + ); + if (mounted) _player.play(); + } + + void _share() { + Share.share( + '${_currentItem.title}\n${_currentItem.videoUrl}', + subject: _currentItem.title, + ); + } + + // ---------- 系列 ---------- + Future _loadSeries() async { - final data = await VideoService.fetchVideoSeries(widget.item.videoId); + final data = await VideoService.fetchVideoSeries(_currentItem.videoId); if (!mounted) return; setState(() => _seriesData = data); _initEpisodePage(); @@ -99,206 +226,21 @@ class _SimplePlayerScreenState extends State { }).toList(); _currentPage = _episodeItems.indexWhere( - (e) => e.videoId == widget.item.videoId, + (e) => e.videoId == _currentItem.videoId, ); if (_currentPage < 0) _currentPage = 0; - _pageController = PageController(initialPage: _currentPage); } - @override - void dispose() { - _pauseIconTimer?.cancel(); - _posSub?.cancel(); - _durSub?.cancel(); - _player.dispose(); - if (_hasSeries) _pageController.dispose(); - super.dispose(); - } - - // ---------- 播放控制 ---------- - - void _toggle() { - setState(() => _playing = !_playing); - _playing ? _player.play() : _player.pause(); - setState(() => _showPauseIcon = true); - _pauseIconTimer?.cancel(); - _pauseIconTimer = Timer(const Duration(milliseconds: 600), () { - if (mounted) setState(() => _showPauseIcon = false); - }); - } - - // ---------- 进度条 seek ---------- - - double get _sliderValue { - final total = _duration.inMilliseconds; - if (total <= 0) return 0; - final cur = _position.inMilliseconds.clamp(0, total); - return cur / total; - } - - void _onSeekStart(double v) { - _seeking = true; - } - - void _onSeek(double v) { - setState(() {}); - } - - void _onSeekEnd(double v) { - _seeking = false; - final totalMs = _duration.inMilliseconds; - if (totalMs > 0) { - _player.seek(Duration(milliseconds: (v * totalMs).round())); - } - } - - // ---------- 倍速 ---------- - - static const _rates = [1.0, 1.25, 1.5, 2.0]; - - void _setRate(double r) { - _player.setRate(r); - setState(() => _rate = r); - } - - // ---------- 系列 ---------- - - bool get _hasSeries => - _seriesData != null && (_seriesData!['has_series'] as bool? ?? false); - - List get _episodes => (_seriesData?['episodes'] as List?) ?? const []; - - void _showSeriesSheet() { - showModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - isScrollControlled: true, - builder: (_) => DraggableScrollableSheet( - initialChildSize: 0.65, - minChildSize: 0.4, - maxChildSize: 0.9, - builder: (_, controller) => Container( - decoration: const BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.vertical(top: Radius.circular(20)), - ), - child: Column( - children: [ - Center( - child: Container( - margin: const EdgeInsets.only(top: 12), - width: 40, height: 4, - decoration: BoxDecoration( - color: Colors.grey[300], - borderRadius: BorderRadius.circular(2), - ), - ), - ), - Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: (_seriesData!['series']['cover_url'] as String?) - ?.isNotEmpty == - true - ? Image.network( - _seriesData!['series']['cover_url'], - width: 64, height: 64, fit: BoxFit.cover, - errorBuilder: (_, __, ___) => - const SizedBox(width: 64, height: 64), - ) - : Container( - width: 64, height: 64, color: Colors.grey[200]), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _seriesData!['series']['title'] ?? '', - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.w600), - maxLines: 1, overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - '共 ${_seriesData!['series']['total_episodes']} 集', - style: TextStyle( - color: Colors.grey[600], fontSize: 12), - ), - ], - ), - ), - ], - ), - ), - const Divider(height: 1), - Expanded( - child: GridView.builder( - controller: controller, - padding: const EdgeInsets.all(16), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - childAspectRatio: 1, - ), - itemCount: _episodes.length, - itemBuilder: (_, i) { - final ep = _episodes[i] as Map; - final isCurrent = - ep['video_id'] == widget.item.videoId; - return GestureDetector( - onTap: () => _switchToEpisode(ep), - child: Container( - decoration: BoxDecoration( - color: isCurrent - ? AppColors.primary.withValues(alpha: 0.15) - : Colors.grey[100], - border: Border.all( - color: isCurrent - ? AppColors.primary - : Colors.transparent), - borderRadius: BorderRadius.circular(8), - ), - child: Center( - child: Text( - ep['episode_no']?.toString() ?? '${i + 1}', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: isCurrent - ? AppColors.primary - : Colors.black87, - ), - ), - ), - ), - ); - }, - ), - ), - ], - ), - ), - ), - ); - } - - void _switchToEpisode(Map ep) { - Navigator.of(context).pop(); - _switchToEpisodeById(ep['video_id'] as int); + void _onPageChanged(int index) { + if (index < 0 || index >= _episodeItems.length) return; + _switchToEpisodeById(_episodeItems[index].videoId); } void _switchToEpisodeById(int videoId) { - if (videoId == widget.item.videoId) return; + if (videoId == _currentItem.videoId) return; - // 从 episodes 里找到目标集,构造新的 VideoItem + // 从 episodes 原始数据构造新 VideoItem final epData = _episodes.firstWhere( (e) => (e as Map)['video_id'] == videoId, orElse: () => null, @@ -322,33 +264,28 @@ class _SimplePlayerScreenState extends State { setState(() { _player = Player(); _controller = VideoController(_player); + _currentItem = newItem; _position = Duration.zero; _duration = Duration.zero; _playing = true; + _rate = 1.0; _open(newItem); }); _subscribePlayer(); _reportWatch(); _loadSeries(); - } - - // ---------- 上下划切换(系列) ---------- - - void _onPageChanged(int index) { - if (index == _currentPage) return; - _currentPage = index; - _switchToEpisodeById(_episodeItems[index].videoId); + _loadInteraction(); } // ---------- Watch ---------- Future _reportWatch() async { final dedup = WatchDedupStore.I; - if (dedup.hasWatched(widget.item.videoId)) return; - await dedup.markWatched(widget.item.videoId); + if (dedup.hasWatched(_currentItem.videoId)) return; + await dedup.markWatched(_currentItem.videoId); try { - final result = await VideoService.reportWatch(widget.item.videoId); + final result = await VideoService.reportWatch(_currentItem.videoId); if (!mounted) return; if (result.isPointsCost) { showTopToast(context, '本次消耗 ${result.costPoints} 积分'); @@ -369,11 +306,163 @@ class _SimplePlayerScreenState extends State { } catch (_) {} } + // ---------- 长按 ---------- + + void _onLongPress() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (sheetContext) => Padding( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 标题 + 当前倍速 + Row( + children: [ + const Icon(Icons.speed, color: AppColors.primary, size: 20), + const SizedBox(width: 6), + const Text('倍速播放', + style: TextStyle( + fontSize: 15, fontWeight: FontWeight.w600)), + const Spacer(), + Text('当前 $_rateLabel', + style: TextStyle(fontSize: 12, color: Colors.grey[600])), + ], + ), + const SizedBox(height: 14), + + // 四档倍速:1X / 1.5X / 2X / 3X + Row( + children: [ + for (final r in _rates) ...[ + Expanded( + child: GestureDetector( + onTap: () { + _setRate(r); + Navigator.of(sheetContext).pop(); + }, + child: Container( + height: 46, + alignment: Alignment.center, + decoration: BoxDecoration( + color: (_rate - r).abs() < 0.01 + ? AppColors.primary.withValues(alpha: 0.15) + : Colors.grey[100], + border: Border.all( + color: (_rate - r).abs() < 0.01 + ? AppColors.primary + : Colors.transparent, + ), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + r == r.roundToDouble() + ? '${r.toInt()}X' + : '${r}X', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: (_rate - r).abs() < 0.01 + ? AppColors.primary + : Colors.black87, + ), + ), + ), + ), + ), + if (r != _rates.last) const SizedBox(width: 10), + ], + ], + ), + + const SizedBox(height: 16), + const Divider(height: 1), + + // 分享 + ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.share_outlined), + title: const Text('分享'), + onTap: () { + Navigator.of(sheetContext).pop(); + _share(); + }, + ), + ], + ), + ), + ); + } + + // ---------- 进度条 ---------- + + double get _sliderValue { + if (_seeking) return _seekValue; + if (_duration.inMilliseconds <= 0) return 0; + return (_position.inMilliseconds / _duration.inMilliseconds) + .clamp(0.0, 1.0); + } + + void _onSeekStart(double v) { + setState(() { + _seeking = true; + _seekValue = v; + }); + } + + void _onSeek(double v) { + setState(() => _seekValue = v); + } + + void _onSeekEnd(double v) { + setState(() => _seeking = false); + if (_duration.inMilliseconds > 0) { + final target = Duration( + milliseconds: (v * _duration.inMilliseconds).round(), + ); + _player.seek(target); + } + } + + // ---------- 生命周期 ---------- + + @override + void dispose() { + _pauseIconTimer?.cancel(); + _posSub?.cancel(); + _durSub?.cancel(); + _player.dispose(); + if (_hasSeries && _episodeItems.isNotEmpty) { + _pageController.dispose(); + } + super.dispose(); + } + + // ---------- 构建 ---------- + @override Widget build(BuildContext context) { + // 互动栏 item(用当前播放的 item + 本地互动状态) + final interactionItem = _currentItem.copyWith( + isLiked: _isLiked, + isFavorited: _isFavorited, + likeCount: _likeCount, + favoriteCount: _favoriteCount, + commentCount: _commentCount, + ); + final content = GestureDetector( behavior: HitTestBehavior.opaque, onTap: _toggle, + onDoubleTap: _onLike, + onLongPress: _onLongPress, child: Stack( fit: StackFit.expand, children: [ @@ -398,78 +487,111 @@ class _SimplePlayerScreenState extends State { ), ), - // ---------- 顶部:返回 + 倍速 + 系列 ---------- + // 底部渐变(提升文字可读性) + const Positioned( + left: 0, right: 0, bottom: 0, height: 200, + child: IgnorePointer( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black54], + ), + ), + ), + ), + ), + + // ---------- 顶部:返回 + 倍速 ---------- SafeArea( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const BackButton(color: Colors.white), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - // 倍速 - PopupMenuButton( - initialValue: _rate, - icon: const Icon(Icons.speed, color: Colors.white), - tooltip: '倍速', - onSelected: _setRate, - itemBuilder: (_) => _rates - .map((r) => PopupMenuItem( - value: r, - child: Text('${r}x'), - )) - .toList(), + // 倍速按钮 + GestureDetector( + onTap: _onLongPress, + child: Container( + margin: const EdgeInsets.only(right: 16), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: Colors.black45, + borderRadius: BorderRadius.circular(8), ), - if (_hasSeries) - IconButton( - icon: const Icon(Icons.menu_book, color: Colors.white), - tooltip: '查看系列', - onPressed: _showSeriesSheet, + child: Text( + _rateLabel, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, ), - ], + ), + ), ), ], ), ), - // ---------- 底部:标题 + 抖音风格细进度条 ---------- + // ---------- 右侧互动栏 ---------- Positioned( - left: 0, right: 0, bottom: 0, - child: IgnorePointer( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '@${widget.item.author.nickname}', - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600, - color: Colors.white), - ), - const SizedBox(height: 2), - Text( - widget.item.title, - style: const TextStyle( - fontSize: 12, color: Colors.white70), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], - ), - ), - ], - ), + right: 8, bottom: 96, + child: InteractionBar( + item: interactionItem, + onLike: (_) => _onLike(), + onFavorite: (_) => _onFavorite(), + onComment: (_) => _openComments(), ), ), - // 进度条:抖音风格一条细线,覆盖视频底部 + // ---------- 左下信息区 ---------- + Positioned( + left: 12, right: 88, bottom: 24, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // 系列标题(当前视频属于系列时显示,点击弹出系列列表) + if (_hasSeries) + GestureDetector( + onTap: _showSeriesSheet, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.black54, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.playlist_play, + color: Colors.white, size: 16), + const SizedBox(width: 4), + Text( + _seriesTitle, + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(width: 2), + const Icon(Icons.chevron_right, + color: Colors.white, size: 14), + ], + ), + ), + ), + // 视频信息(昵称/标题/描述/标签) + VideoInfoSection(item: _currentItem), + ], + ), + ), + + // ---------- 进度条:抖音风格一条细线 ---------- Positioned( left: 0, right: 0, bottom: 0, child: _ThinProgressBar( @@ -493,10 +615,7 @@ class _SimplePlayerScreenState extends State { itemCount: _episodeItems.length, onPageChanged: _onPageChanged, itemBuilder: (_, index) { - // 只有当前页才渲染完整播放器(其他页只放占位) - if (index == _currentPage) { - return content; - } + if (index == _currentPage) return content; return Container( color: Colors.black, child: Center( @@ -517,6 +636,117 @@ class _SimplePlayerScreenState extends State { body: content, ); } + + // ---------- 系列列表弹窗 ---------- + + void _showSeriesSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(16)), + ), + builder: (_) => DraggableScrollableSheet( + initialChildSize: 0.6, + minChildSize: 0.4, + maxChildSize: 0.9, + expand: false, + builder: (_, controller) => Column( + children: [ + Container( + margin: const EdgeInsets.only(top: 12), + width: 40, height: 4, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(2), + ), + ), + Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _seriesTitle, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w600), + maxLines: 1, overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 2), + Text( + '共 ${_episodes.length} 集', + style: TextStyle( + fontSize: 12, color: Colors.grey[600]), + ), + ], + ), + ), + ], + ), + ), + const Divider(height: 1), + Expanded( + child: ListView.builder( + controller: controller, + itemCount: _episodes.length, + itemBuilder: (_, i) { + final ep = _episodes[i] as Map; + final isCurrent = ep['video_id'] == _currentItem.videoId; + return ListTile( + leading: Container( + width: 40, height: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + color: isCurrent + ? AppColors.primary.withValues(alpha: 0.15) + : Colors.grey[100], + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${ep['episode_no'] ?? i + 1}', + style: TextStyle( + fontWeight: FontWeight.w700, + color: isCurrent + ? AppColors.primary + : Colors.black87, + ), + ), + ), + title: Text( + ep['title'] as String? ?? '', + maxLines: 1, overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 14, + fontWeight: isCurrent + ? FontWeight.w600 + : FontWeight.normal, + color: isCurrent + ? AppColors.primary + : Colors.black87, + ), + ), + trailing: isCurrent + ? const Icon(Icons.play_circle_filled, + color: AppColors.primary, size: 20) + : null, + onTap: () { + Navigator.of(context).pop(); + _switchToEpisodeById(ep['video_id'] as int); + }, + ); + }, + ), + ), + ], + ), + ), + ); + } } /// 抖音风格进度条:默认一条 2px 细线(已播放部分白色、未播放部分 20% 透明白), @@ -579,18 +809,14 @@ class _ThinProgressBarState extends State<_ThinProgressBar> { height: 32, // 触摸热区 32px(比视觉线条大,提升交互) alignment: Alignment.bottomCenter, child: AnimatedContainer( - duration: _dragging - ? Duration.zero - : const Duration(milliseconds: 150), - curve: Curves.easeOut, - height: _dragging ? 4.0 : 2.0, // 拖动时变厚 - color: Colors.transparent, + duration: const Duration(milliseconds: 150), + height: _dragging ? 4 : 2, + width: double.infinity, child: CustomPaint( painter: _ProgressPainter( value: _dragging ? _dragValue : widget.value, dragging: _dragging, ), - size: Size(w, _dragging ? 20 : 4), ), ), ), @@ -609,37 +835,34 @@ class _ProgressPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { final w = size.width; - final h = dragging ? 3.0 : 2.0; - final y = (size.height - h) / 2; + final h = size.height; + final y = h / 2; - // 背景:未播放部分(20% 透明白) - final trackPaint = Paint() - ..color = Colors.white.withValues(alpha: dragging ? 0.35 : 0.22) - ..strokeCap = StrokeCap.round - ..strokeWidth = h; - canvas.drawLine(Offset(0, y), Offset(w, y), trackPaint); + // 背景(未播放) + final bgPaint = Paint() + ..color = Colors.white.withValues(alpha: 0.2) + ..strokeWidth = h + ..strokeCap = StrokeCap.round; + canvas.drawLine(Offset(0, y), Offset(w, y), bgPaint); - // 已播放部分(白色) - if (value > 0) { - final playedPaint = Paint() - ..color = Colors.white - ..strokeCap = StrokeCap.round - ..strokeWidth = h; - canvas.drawLine(Offset(0, y), Offset(value * w, y), playedPaint); - } + // 已播放 + final fgPaint = Paint() + ..color = Colors.white + ..strokeWidth = h + ..strokeCap = StrokeCap.round; + final playedX = (w * value).clamp(0.0, w); + canvas.drawLine(Offset(0, y), Offset(playedX, y), fgPaint); - // 拖动时的 thumb + // 拖动时显示 thumb if (dragging) { - canvas.drawCircle( - Offset(value * w, y), 6, - Paint() - ..color = Colors.white - ..style = PaintingStyle.fill, - ); + final thumbPaint = Paint() + ..color = Colors.white + ..style = PaintingStyle.fill; + canvas.drawCircle(Offset(playedX, y), 6, thumbPaint); } } @override - bool shouldRepaint(covariant _ProgressPainter old) => - value != old.value || dragging != old.dragging; + bool shouldRepaint(covariant _ProgressPainter oldDelegate) => + oldDelegate.value != value || oldDelegate.dragging != dragging; } diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 6b1c3bd..18d9e2f 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -2,7 +2,7 @@ name: video_app description: "短视频应用(Flutter,iOS/Android 双端)" publish_to: 'none' -version: 1.1.8+10 +version: 1.2.0+12 environment: sdk: ^3.9.2 diff --git a/server/app/api/v1/__pycache__/videos.cpython-311.pyc b/server/app/api/v1/__pycache__/videos.cpython-311.pyc index 29ea0ca..635b3a1 100644 Binary files a/server/app/api/v1/__pycache__/videos.cpython-311.pyc and b/server/app/api/v1/__pycache__/videos.cpython-311.pyc differ diff --git a/server/app/api/v1/videos.py b/server/app/api/v1/videos.py index 4e1f21d..6bac7ba 100644 --- a/server/app/api/v1/videos.py +++ b/server/app/api/v1/videos.py @@ -196,6 +196,7 @@ async def get_video_series(video_id: int, db: AsyncSession = Depends(get_db)): "episode_no": v.episode_no, "title": v.title, "cover_url": v.cover_url, + "video_url": v.video_url, "duration": v.duration, } for v in all_eps