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

import 'package:chewie/chewie.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:video_player/video_player.dart';
import 'package:wow_english/common/request/dao/video_dao.dart';
import 'package:wow_english/common/utils/video_progress_cache.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;
  ChewieController? _chewieController;
  Timer? _cacheTimer;
  late int _currentIndex;
  double _playbackSpeed = 1;
  bool _switchingVideo = false;
  bool _handlingCompletion = false;
  bool? _lastIsPlaying;
  int? _completedReportedVideoId;
  Object? _loadError;

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

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

  Future<void> _loadCurrentVideo() async {
    final useCupertinoControls = defaultTargetPlatform == TargetPlatform.iOS;
    _cacheTimer?.cancel();
    final oldController = _controller;
    final oldChewieController = _chewieController;
    if (oldController?.value.isInitialized == true) {
      _playbackSpeed = oldController!.value.playbackSpeed;
    }
    oldController?.removeListener(_onVideoChanged);
    _lastIsPlaying = null;
    final controller =
        VideoPlayerController.networkUrl(Uri.parse(_currentItem.videoUrl));
    _controller = controller;
    _chewieController = null;
    _loadError = null;
    if (mounted) {
      setState(() {});
    }
    oldChewieController?.dispose();
    await oldController?.dispose();

    try {
      await controller.initialize().timeout(const Duration(seconds: 20));
      if (!mounted || controller != _controller) {
        await controller.dispose();
        return;
      }
      await controller.setLooping(false);
      await controller.setPlaybackSpeed(_playbackSpeed);
      final savedProgress = await _resumeProgressFor(_currentItem.id);
      if (!mounted || controller != _controller) {
        await controller.dispose();
        return;
      }
      if (savedProgress > 0 &&
          savedProgress < controller.value.duration.inSeconds) {
        await controller.seekTo(Duration(seconds: savedProgress));
      }
      _lastIsPlaying = controller.value.isPlaying;
      controller.addListener(_onVideoChanged);
      _chewieController = ChewieController(
        videoPlayerController: controller,
        autoPlay: true,
        looping: false,
        allowFullScreen: false,
        allowPlaybackSpeedChanging: true,
        draggableProgressBar: true,
        allowedScreenSleep: false,
        playbackSpeeds: const [0.5, 0.75, 1, 1.25, 1.5, 2],
        customControls:
            useCupertinoControls ? null : const _VideoMaterialControls(),
        optionsTranslation: OptionsTranslation(
          playbackSpeedButtonText: '播放速度',
          cancelButtonText: '取消',
        ),
        materialProgressColors: ChewieProgressColors(
          playedColor: const Color(0xFF00B6F1),
          handleColor: const Color(0xFF00B6F1),
          bufferedColor: Colors.white54,
          backgroundColor: Colors.white24,
        ),
        cupertinoProgressColors: ChewieProgressColors(
          playedColor: const Color(0xFF00B6F1),
          handleColor: const Color(0xFF00B6F1),
          bufferedColor: Colors.white54,
          backgroundColor: Colors.white24,
        ),
        placeholder: const ColoredBox(color: Colors.black),
        errorBuilder: (_, __) => const Center(
          child: Text(
            '视频播放失败',
            style: TextStyle(color: Colors.white, fontSize: 18),
          ),
        ),
      );
      _handlingCompletion = false;
      _cacheTimer = Timer.periodic(
        const Duration(seconds: 30),
        (_) {
          if (_controller?.value.isPlaying == true) {
            unawaited(_cacheCurrentProgress());
          }
        },
      );
      setState(() {});
    } catch (error) {
      if (mounted && controller == _controller) {
        setState(() => _loadError = error);
      }
    }
  }

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

  Future<void> _playNext({bool currentCompleted = false}) async {
    if (_switchingVideo || _currentIndex >= widget.items.length - 1) return;
    _switchingVideo = true;
    await _reportCurrentProgress(completed: currentCompleted);
    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);
    }
    _handlingCompletion = false;
  }

  @override
  void dispose() {
    WidgetsBinding.instance.removeObserver(this);
    unawaited(
      SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
    );
    _cacheTimer?.cancel();
    _controller?.removeListener(_onVideoChanged);
    unawaited(_reportCurrentProgress());
    _chewieController?.dispose();
    _controller?.dispose();
    super.dispose();
  }

  @override
  void didChangeAppLifecycleState(AppLifecycleState state) {
    if (state == AppLifecycleState.resumed) {
      unawaited(
        SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky),
      );
    } else if (state == AppLifecycleState.inactive ||
        state == AppLifecycleState.paused ||
        state == AppLifecycleState.detached) {
      unawaited(_cacheCurrentProgress());
    }
  }

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

  Future<int> _resumeProgressFor(int videoId) async {
    try {
      final cached = await VideoProgressCache.get(videoId);
      if (cached != null) return cached.progressSeconds;
    } catch (error) {
      debugPrint('读取本地视频播放进度失败: $error');
    }
    return 0;
  }

  Future<void> _cacheCurrentProgress() async {
    final controller = _controller;
    if (controller == null || !controller.value.isInitialized) return;
    final currentItem = _currentItem;
    final durationSeconds = controller.value.duration.inSeconds;
    final progressSeconds = controller.value.position.inSeconds
        .clamp(0, durationSeconds > 0 ? durationSeconds : 0);
    await _persistCachedProgress(
      videoId: currentItem.id,
      progressSeconds: progressSeconds,
      durationSeconds: durationSeconds,
      completed: controller.value.isCompleted,
    );
  }

  Future<void> _persistCachedProgress({
    required int videoId,
    required int progressSeconds,
    required int durationSeconds,
    required bool completed,
  }) async {
    try {
      if (completed) {
        await VideoProgressCache.remove(videoId);
      } else {
        await VideoProgressCache.save(
          videoId: videoId,
          progressSeconds: progressSeconds,
          durationSeconds: durationSeconds,
        );
      }
    } catch (error) {
      debugPrint('保存本地视频播放进度失败: $error');
    }
  }

  @override
  Widget build(BuildContext context) {
    final controller = _controller;
    final chewieController = _chewieController;
    return MediaQuery.removePadding(
      context: context,
      removeTop: true,
      child: Scaffold(
        backgroundColor: Colors.black,
        body: Stack(
          fit: StackFit.expand,
          children: [
            if (controller?.value.isInitialized == true &&
                chewieController != null)
              Chewie(controller: chewieController)
            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()),
            SafeArea(
              top: false,
              child: Align(
                alignment: Alignment.topLeft,
                child: 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),
                      ),
                    ),
                  ],
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _VideoMaterialControls extends StatelessWidget {
  const _VideoMaterialControls();

  @override
  Widget build(BuildContext context) {
    return Theme(
      data: Theme.of(context).copyWith(
        iconButtonTheme: IconButtonThemeData(
          style: IconButton.styleFrom(
            minimumSize: const Size.square(72),
            padding: const EdgeInsets.all(20),
          ),
        ),
      ),
      child: const MaterialControls(),
    );
  }
}