lum_splash_video/lib/widgets/splash_video_page_w.dart

350 lines
10 KiB
Dart

/*
* 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 SplashVideoPage 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 SplashVideoPage({
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<bool> 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<SplashVideoPage> createState() => _SplashVideoPageState();
}
class _SplashVideoPageState extends State<SplashVideoPage> {
Timer? _completionTimer;
StreamSubscription? _skipSubscription;
late final VoidCallback onSourceLoaded;
@override
void initState() {
super.initState();
// Validate configuration
_validateConfiguration();
onSourceLoaded = widget.onSourceLoaded ?? SplashVideoPage.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
}
}