350 lines
10 KiB
Dart
350 lines
10 KiB
Dart
/*
|
|
* Copyright (c) 2026 Malloc LLC (malloc.io)
|
|
*
|
|
* 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<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<SplashVideo> createState() => _SplashVideoState();
|
|
}
|
|
|
|
class _SplashVideoState extends State<SplashVideo> {
|
|
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
|
|
}
|
|
}
|