video_zone_player_page.dart 11 KB
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:video_player/video_player.dart';
import 'package:wow_english/common/request/dao/video_dao.dart';

import 'video_zone_item.dart';

class VideoZonePlayerPage extends StatefulWidget {
  const VideoZonePlayerPage({
    super.key,
    required this.items,
    this.initialVideoId,
  }) : assert(items.length > 0, '视频播放列表不能为空');

  final List<VideoZoneItem> items;
  final int? initialVideoId;

  @override
  State<VideoZonePlayerPage> createState() => _VideoZonePlayerPageState();
}

class _VideoZonePlayerPageState extends State<VideoZonePlayerPage>
    with WidgetsBindingObserver {
  VideoPlayerController? _controller;
  Timer? _progressTimer;
  late int _currentIndex;
  double _playbackSpeed = 1;
  bool _showControls = true;
  bool _switchingVideo = false;
  bool _handlingCompletion = false;
  int? _lastReportedPosition;
  int? _completedReportedVideoId;
  Object? _loadError;

  VideoZoneItem get _currentItem => widget.items[_currentIndex];

  @override
  void initState() {
    super.initState();
    WidgetsBinding.instance.addObserver(this);
    final index = widget.initialVideoId == null
        ? 0
        : widget.items.indexWhere((item) => item.id == widget.initialVideoId);
    _currentIndex = index < 0 ? 0 : index;
    _loadCurrentVideo();
  }

  Future<void> _loadCurrentVideo() async {
    _progressTimer?.cancel();
    final oldController = _controller;
    oldController?.removeListener(_onVideoChanged);
    final controller =
        VideoPlayerController.networkUrl(Uri.parse(_currentItem.videoUrl));
    _controller = controller;
    _loadError = null;
    if (mounted) {
      setState(() {});
    }
    await oldController?.dispose();

    try {
      await controller.initialize();
      if (!mounted || controller != _controller) {
        await controller.dispose();
        return;
      }
      await controller.setLooping(false);
      await controller.setPlaybackSpeed(_playbackSpeed);
      final savedProgress =
          _currentItem.completed ? 0 : _currentItem.progressSeconds;
      if (savedProgress > 0 &&
          savedProgress < controller.value.duration.inSeconds) {
        await controller.seekTo(Duration(seconds: savedProgress));
      }
      controller.addListener(_onVideoChanged);
      await controller.play();
      _lastReportedPosition = null;
      _handlingCompletion = false;
      _progressTimer = Timer.periodic(
        const Duration(seconds: 5),
        (_) => _reportCurrentProgress(),
      );
      setState(() {});
    } catch (error) {
      if (mounted && controller == _controller) {
        setState(() => _loadError = error);
      }
    }
  }

  void _onVideoChanged() {
    final controller = _controller;
    if (!mounted || controller == null) return;
    if (controller.value.isCompleted &&
        !_switchingVideo &&
        !_handlingCompletion &&
        _completedReportedVideoId != _currentItem.id) {
      _handlingCompletion = true;
      unawaited(_handleVideoCompleted());
      return;
    }
    setState(() {});
  }

  Future<void> _playNext({bool currentCompleted = false}) async {
    if (_switchingVideo || _currentIndex >= widget.items.length - 1) return;
    _switchingVideo = true;
    await _reportCurrentProgress(completed: currentCompleted, force: true);
    setState(() => _currentIndex++);
    await _loadCurrentVideo();
    _switchingVideo = false;
  }

  Future<void> _handleVideoCompleted() async {
    if (_currentIndex < widget.items.length - 1) {
      await _playNext(currentCompleted: true);
    } else {
      await _reportCurrentProgress(completed: true, force: true);
    }
    _handlingCompletion = false;
  }

  Future<void> _togglePlay() async {
    final controller = _controller;
    if (controller == null || !controller.value.isInitialized) return;
    if (controller.value.isCompleted) {
      await controller.seekTo(Duration.zero);
      _completedReportedVideoId = null;
    }
    if (controller.value.isPlaying) {
      await controller.pause();
      await _reportCurrentProgress(force: true);
    } else {
      await controller.play();
    }
    if (mounted) setState(() {});
  }

  Future<void> _setSpeed(double speed) async {
    _playbackSpeed = speed;
    await _controller?.setPlaybackSpeed(speed);
    if (mounted) setState(() {});
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    _progressTimer?.cancel();
    _controller?.removeListener(_onVideoChanged);
    unawaited(_reportCurrentProgress(force: true));
    _controller?.dispose();
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.inactive ||
        state == AppLifecycleState.paused ||
        state == AppLifecycleState.detached) {
      unawaited(_reportCurrentProgress(force: true));
    }
  }

  Future<void> _reportCurrentProgress({
    bool? completed,
    bool force = false,
  }) async {
    final controller = _controller;
    if (controller == null || !controller.value.isInitialized) return;
    final durationSeconds = controller.value.duration.inSeconds > 0
        ? controller.value.duration.inSeconds
        : _currentItem.durationSeconds;
    final progressSeconds = controller.value.position.inSeconds
        .clamp(0, durationSeconds > 0 ? durationSeconds : 0);
    final isCompleted = completed ?? controller.value.isCompleted;
    if (!force && _lastReportedPosition == progressSeconds) return;
    if (isCompleted && _completedReportedVideoId == _currentItem.id) return;
    _lastReportedPosition = progressSeconds;
    try {
      await VideoDao.savePlayRecord(
        videoId: _currentItem.id,
        progressSeconds: progressSeconds,
        durationSeconds: durationSeconds,
        completed: isCompleted,
      );
      if (isCompleted) _completedReportedVideoId = _currentItem.id;
    } catch (error) {
      debugPrint('保存视频播放记录失败: $error');
    }
  }

  @override
  Widget build(BuildContext context) {
    final controller = _controller;
    return Scaffold(
      backgroundColor: Colors.black,
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: () => setState(() => _showControls = !_showControls),
        onDoubleTap: _togglePlay,
        child: Stack(
          fit: StackFit.expand,
          children: [
            if (controller?.value.isInitialized == true)
              Center(
                child: AspectRatio(
                  aspectRatio: controller!.value.aspectRatio,
                  child: VideoPlayer(controller),
                ),
              )
            else if (_loadError != null)
              Center(
                child: Column(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    const Text('视频加载失败',
                        style: TextStyle(color: Colors.white, fontSize: 18)),
                    const SizedBox(height: 12),
                    FilledButton(
                        onPressed: _loadCurrentVideo, child: const Text('重试')),
                  ],
                ),
              )
            else
              const Center(child: CircularProgressIndicator()),
            if (_showControls) _buildControls(controller),
          ],
        ),
      ),
    );
  }

  Widget _buildControls(VideoPlayerController? controller) {
    final initialized = controller?.value.isInitialized == true;
    return ColoredBox(
      color: Colors.black38,
      child: SafeArea(
        child: Column(
          children: [
            Row(
              children: [
                IconButton(
                  onPressed: () => Navigator.of(context).pop(),
                  icon:
                      const Icon(Icons.arrow_back_ios_new, color: Colors.white),
                ),
                Expanded(
                  child: Text(
                    _currentItem.title,
                    maxLines: 1,
                    overflow: TextOverflow.ellipsis,
                    style: const TextStyle(color: Colors.white, fontSize: 20),
                  ),
                ),
                PopupMenuButton<double>(
                  initialValue: _playbackSpeed,
                  onSelected: _setSpeed,
                  itemBuilder: (_) => const [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
                      .map((speed) => PopupMenuItem(
                            value: speed,
                            child: Text('${speed}x'),
                          ))
                      .toList(),
                  child: Padding(
                    padding: const EdgeInsets.symmetric(
                        horizontal: 18, vertical: 12),
                    child: Text('${_playbackSpeed}x',
                        style:
                            const TextStyle(color: Colors.white, fontSize: 16)),
                  ),
                ),
              ],
            ),
            const Spacer(),
            if (initialized)
              IconButton(
                onPressed: _togglePlay,
                iconSize: 68,
                color: Colors.white,
                icon: Icon(controller!.value.isPlaying
                    ? Icons.pause_circle_filled
                    : Icons.play_circle_fill),
              ),
            const Spacer(),
            if (initialized)
              Padding(
                padding: const EdgeInsets.fromLTRB(20, 0, 20, 12),
                child: Row(
                  children: [
                    Expanded(
                      child: VideoProgressIndicator(
                        controller!,
                        allowScrubbing: true,
                        padding: const EdgeInsets.symmetric(vertical: 12),
                        colors: const VideoProgressColors(
                          playedColor: Color(0xFF00B6F1),
                          bufferedColor: Colors.white54,
                          backgroundColor: Colors.white24,
                        ),
                      ),
                    ),
                    const SizedBox(width: 12),
                    Text(
                      '${_formatDuration(controller.value.position)} / '
                      '${_formatDuration(controller.value.duration)}',
                      style: const TextStyle(color: Colors.white),
                    ),
                    if (_currentIndex < widget.items.length - 1) ...[
                      const SizedBox(width: 8),
                      IconButton(
                        tooltip: '下一集',
                        onPressed: _playNext,
                        icon: const Icon(Icons.skip_next, color: Colors.white),
                      ),
                    ],
                  ],
                ),
              ),
          ],
        ),
      ),
    );
  }

  String _formatDuration(Duration duration) {
    final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0');
    final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0');
    if (duration.inHours == 0) return '$minutes:$seconds';
    return '${duration.inHours.toString().padLeft(2, '0')}:$minutes:$seconds';
  }
}