/* * Copyright (c) 2025 * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart'; import 'package:splash_video/utils.dart'; import 'package:splash_video/video_config.dart'; import 'package:splash_video/splash_video_controller.dart'; import 'package:splash_video/video_source.dart'; import 'package:splash_video/widgets/splash_video_player_w.dart'; /// Main widget for displaying a video splash screen /// /// This widget handles video playback, overlays, navigation, and lifecycle management. /// It provides a smooth transition from native splash screen to video splash. class SplashVideo extends StatefulWidget { /// Creates a SplashVideo widget /// /// [source] is required and specifies the video source /// [controller] is required when using looping videos /// /// Throws [AssertionError] if validation rules are violated const SplashVideo({ super.key, required this.source, this.controller, this.videoConfig, this.backgroundColor, this.titleWidget, this.footerWidget, this.overlayBuilder, this.nextScreen, this.onVideoComplete, this.onSourceLoaded, this.onVideoError, }); /// Source for the video (asset, network, file, or bytes) final Source source; /// Controller for managing video playback /// /// Required when [controller.loopVideo] is true final SplashVideoController? controller; /// Configuration options for video playback final VideoConfig? videoConfig; /// Background color displayed behind the video final Color? backgroundColor; /// Widget displayed at the top of the screen over the video final Widget? titleWidget; /// Widget displayed at the bottom of the screen over the video final Widget? footerWidget; /// Custom overlay builder for full control over UI elements /// /// This is rendered last and provides complete flexibility final Widget Function(BuildContext)? overlayBuilder; /// Screen to navigate to when video completes (auto-navigation) /// /// Cannot be used with [onVideoComplete] or looping videos final Widget? nextScreen; /// Callback invoked when video completes (manual control) /// /// Takes priority over [nextScreen]. Cannot be used with looping videos final VoidCallback? onVideoComplete; /// Callback invoked when video source is loaded /// /// Defaults to calling [resume] to allow Flutter frames to render final VoidCallback? onSourceLoaded; /// Callback invoked when video fails to load /// /// If not provided, a default error UI will be shown final OnVideoError? onVideoError; // Track whether the first frame has been deferred static bool _firstFrameDeferred = false; /// Initializes MediaKit and prevents Flutter frames from rendering /// /// Call this in main() before runApp to: /// 1. Defer first frame ASAP to prevent jank /// 2. Initialize the MediaKit library for video playback /// 3. Keep the native splash visible while video loads /// /// ⚠️ IMPORTANT: Only call this if your app's FIRST screen (MaterialApp.home) /// is a SplashVideoPage. If you show a menu/selector first, DON'T call this. /// /// ```dart /// void main() { /// WidgetsFlutterBinding.ensureInitialized(); /// SplashVideoPage.initialize(); // Only if first screen is SplashVideoPage! /// runApp(MyApp()); /// } /// ``` static void initialize() { // Defer first frame ASAP to avoid jank WidgetsBinding.instance.deferFirstFrame(); _firstFrameDeferred = true; // Then initialize MediaKit MediaKit.ensureInitialized(); if (kDebugMode) { debugPrint('✓ SplashVideo: First frame deferred, MediaKit initialized'); debugPrint('⚠️ Waiting for SplashVideoPage to resume rendering...'); debugPrint('⚠️ If your app shows a black screen, make sure SplashVideoPage is your FIRST screen!'); } } /// Tests if MediaKit is properly configured and can create a Player /// /// Returns true if MediaKit is working, false otherwise. /// Prints diagnostic information in debug mode. static Future testMediaKit() async { try { if (kDebugMode) { debugPrint('Testing MediaKit installation...'); } // Initialize MediaKit if not already done MediaKit.ensureInitialized(); // Try to create and dispose a player final testPlayer = Player(); await testPlayer.dispose(); if (kDebugMode) { debugPrint('✓ MediaKit test passed: Player created and disposed successfully'); } return true; } catch (e) { if (kDebugMode) { debugPrint('✗ MediaKit test failed: $e'); debugPrint(' Make sure media_kit platform libraries are added to pubspec.yaml'); } return false; } } /// Resumes Flutter frame rendering /// /// Called automatically by default when video loads, or manually /// through [onSourceLoaded] callback /// /// Only calls allowFirstFrame() if the first frame was actually deferred static void resume() { if (_firstFrameDeferred) { WidgetsBinding.instance.allowFirstFrame(); _firstFrameDeferred = false; if (kDebugMode) { debugPrint('✓ SplashVideo: First frame resumed, app is now visible'); } } } @override State createState() => _SplashVideoState(); } class _SplashVideoState extends State { Timer? _completionTimer; StreamSubscription? _skipSubscription; late final VoidCallback onSourceLoaded; @override void initState() { super.initState(); // Validate configuration _validateConfiguration(); onSourceLoaded = widget.onSourceLoaded ?? SplashVideo.resume; // Listen for skip requests if controller provided if (widget.controller != null) { _listenForSkip(); } } void _validateConfiguration() { // Cannot use both nextScreen and onVideoComplete if (widget.nextScreen != null && widget.onVideoComplete != null) { throw ArgumentError( 'Cannot use both nextScreen and onVideoComplete. ' 'Use onVideoComplete for manual control or nextScreen for auto-navigation.', ); } // Looping videos need controller if (widget.controller?.loopVideo == true) { // Cannot use nextScreen with looping if (widget.nextScreen != null) { throw ArgumentError( 'Cannot use nextScreen with looping videos. ' 'Use controller.skip() to end the video manually.', ); } // Cannot use onVideoComplete with looping if (widget.onVideoComplete != null) { throw ArgumentError( 'Cannot use onVideoComplete with looping videos. ' 'Use controller.skip() to end the video manually.', ); } } } void _listenForSkip() { // Poll for skip requests _skipSubscription = Stream.periodic( const Duration(milliseconds: 100), ).listen((_) { if (widget.controller?.skipRequested == true) { _onSplashComplete(); } }); } @override void dispose() { _completionTimer?.cancel(); _skipSubscription?.cancel(); super.dispose(); } @override Widget build(BuildContext context) { return _buildVideoWithOverlays(); } Widget _buildVideoWithOverlays() { final videoWidget = SplashVideoPlayer( source: widget.source, controller: widget.controller, videoConfig: widget.videoConfig, backgroundColor: widget.backgroundColor, onSplashDuration: _updateSplashDuration, onVideoError: widget.onVideoError, ); // If no overlays, return video directly if (widget.titleWidget == null && widget.footerWidget == null && widget.overlayBuilder == null) { return videoWidget; } // Build stack with overlays return Stack( children: [ videoWidget, if (widget.titleWidget != null) Positioned( top: 0, left: 0, right: 0, child: SafeArea(child: widget.titleWidget!), ), if (widget.footerWidget != null) Positioned( bottom: 0, left: 0, right: 0, child: SafeArea(child: widget.footerWidget!), ), if (widget.overlayBuilder != null) widget.overlayBuilder!(context), ], ); } void _updateSplashDuration(Duration duration) { if (kDebugMode) { debugPrint('SplashVideoPage: Video loaded with duration $duration'); debugPrint(' Calling onSourceLoaded to resume first frame...'); } // Call the onSourceLoaded callback onSourceLoaded.call(); if (kDebugMode) { debugPrint(' ✓ First frame resumed'); } // Don't set timer for looping videos if (widget.controller?.loopVideo == true) { return; } // Set timer to complete after video duration _completionTimer?.cancel(); _completionTimer = Timer(duration, _onSplashComplete); } void _onSplashComplete() { // Prevent multiple calls if (!mounted) return; // Priority 1: onVideoComplete callback if (widget.onVideoComplete != null) { widget.onVideoComplete!.call(); return; } // Priority 2: Auto-navigate to nextScreen if (widget.nextScreen != null) { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => widget.nextScreen!, ), ); return; } // No navigation configured, do nothing } }