1623 lines
54 KiB
Dart
1623 lines
54 KiB
Dart
// Copyright 2013 The Flutter Authors
|
|
// Use of this source code is governed by a BSD-style license that can be
|
|
// found in the LICENSE file.
|
|
|
|
import 'dart:async';
|
|
import 'dart:io';
|
|
import 'dart:math' as math show max;
|
|
|
|
import 'package:collection/collection.dart' as collection;
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:video_player_platform_interface/video_player_platform_interface.dart'
|
|
as platform_interface;
|
|
|
|
import 'src/closed_caption_file.dart';
|
|
|
|
export 'package:video_player_platform_interface/video_player_platform_interface.dart'
|
|
show
|
|
DataSourceType,
|
|
DurationRange,
|
|
VideoFormat,
|
|
VideoPlayerOptions,
|
|
VideoPlayerWebOptions,
|
|
VideoPlayerWebOptionsControls,
|
|
VideoViewType;
|
|
|
|
export 'src/closed_caption_file.dart';
|
|
|
|
/// Represents an audio track in a video with its metadata.
|
|
@immutable
|
|
class VideoAudioTrack {
|
|
/// Constructs an instance of [VideoAudioTrack].
|
|
const VideoAudioTrack({
|
|
required this.id,
|
|
required this.isSelected,
|
|
this.label,
|
|
this.language,
|
|
this.bitrate,
|
|
this.sampleRate,
|
|
this.channelCount,
|
|
this.codec,
|
|
});
|
|
|
|
/// Unique identifier for the audio track.
|
|
final String id;
|
|
|
|
/// Human-readable label for the track.
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final String? label;
|
|
|
|
/// Language code of the audio track (e.g., 'en', 'es', 'und').
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final String? language;
|
|
|
|
/// Whether this track is currently selected.
|
|
final bool isSelected;
|
|
|
|
/// Bitrate of the audio track in bits per second.
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final int? bitrate;
|
|
|
|
/// Sample rate of the audio track in Hz.
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final int? sampleRate;
|
|
|
|
/// Number of audio channels.
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final int? channelCount;
|
|
|
|
/// Audio codec used (e.g., 'aac', 'mp3', 'ac3').
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final String? codec;
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return identical(this, other) ||
|
|
other is VideoAudioTrack &&
|
|
runtimeType == other.runtimeType &&
|
|
id == other.id &&
|
|
label == other.label &&
|
|
language == other.language &&
|
|
isSelected == other.isSelected &&
|
|
bitrate == other.bitrate &&
|
|
sampleRate == other.sampleRate &&
|
|
channelCount == other.channelCount &&
|
|
codec == other.codec;
|
|
}
|
|
|
|
@override
|
|
int get hashCode =>
|
|
Object.hash(id, label, language, isSelected, bitrate, sampleRate, channelCount, codec);
|
|
|
|
@override
|
|
String toString() =>
|
|
'VideoAudioTrack('
|
|
'id: $id, '
|
|
'label: $label, '
|
|
'language: $language, '
|
|
'isSelected: $isSelected, '
|
|
'bitrate: $bitrate, '
|
|
'sampleRate: $sampleRate, '
|
|
'channelCount: $channelCount, '
|
|
'codec: $codec)';
|
|
}
|
|
|
|
/// Converts a platform interface [VideoAudioTrack] to the public API type.
|
|
///
|
|
/// This internal method is used to decouple the public API from the
|
|
/// platform interface implementation.
|
|
VideoAudioTrack _convertPlatformAudioTrack(platform_interface.VideoAudioTrack platformTrack) {
|
|
return VideoAudioTrack(
|
|
id: platformTrack.id,
|
|
label: platformTrack.label,
|
|
language: platformTrack.language,
|
|
isSelected: platformTrack.isSelected,
|
|
bitrate: platformTrack.bitrate,
|
|
sampleRate: platformTrack.sampleRate,
|
|
channelCount: platformTrack.channelCount,
|
|
codec: platformTrack.codec,
|
|
);
|
|
}
|
|
|
|
platform_interface.VideoPlayerPlatform? _lastVideoPlayerPlatform;
|
|
|
|
platform_interface.VideoPlayerPlatform get _videoPlayerPlatform {
|
|
final platform_interface.VideoPlayerPlatform currentInstance =
|
|
platform_interface.VideoPlayerPlatform.instance;
|
|
if (_lastVideoPlayerPlatform != currentInstance) {
|
|
// This will clear all open videos on the platform when a full restart is
|
|
// performed.
|
|
currentInstance.init();
|
|
_lastVideoPlayerPlatform = currentInstance;
|
|
}
|
|
return currentInstance;
|
|
}
|
|
|
|
/// The duration, current position, buffering state, error state and settings
|
|
/// of a [VideoPlayerController].
|
|
@immutable
|
|
class VideoPlayerValue {
|
|
/// Constructs a video with the given values. Only [duration] is required. The
|
|
/// rest will initialize with default values when unset.
|
|
const VideoPlayerValue({
|
|
required this.duration,
|
|
this.size = Size.zero,
|
|
this.position = Duration.zero,
|
|
this.caption = Caption.none,
|
|
this.captionOffset = Duration.zero,
|
|
this.buffered = const <platform_interface.DurationRange>[],
|
|
this.isInitialized = false,
|
|
this.isPlaying = false,
|
|
this.isLooping = false,
|
|
this.isBuffering = false,
|
|
this.volume = 1.0,
|
|
this.playbackSpeed = 1.0,
|
|
this.rotationCorrection = 0,
|
|
this.errorDescription,
|
|
this.isCompleted = false,
|
|
this.preventsDisplaySleepDuringVideoPlayback = true,
|
|
});
|
|
|
|
/// Returns an instance for a video that hasn't been loaded.
|
|
const VideoPlayerValue.uninitialized() : this(duration: Duration.zero, isInitialized: false);
|
|
|
|
/// Returns an instance with the given [errorDescription].
|
|
const VideoPlayerValue.erroneous(String errorDescription)
|
|
: this(duration: Duration.zero, isInitialized: false, errorDescription: errorDescription);
|
|
|
|
/// This constant is just to indicate that parameter is not passed to [copyWith]
|
|
/// workaround for this issue https://github.com/dart-lang/language/issues/2009
|
|
static const String _defaultErrorDescription = 'defaultErrorDescription';
|
|
|
|
/// The total duration of the video.
|
|
///
|
|
/// The value is only meaningful when [isInitialized] is true.
|
|
final Duration duration;
|
|
|
|
/// The current playback position.
|
|
final Duration position;
|
|
|
|
/// The [Caption] that should be displayed based on the current [position].
|
|
///
|
|
/// This field will never be null. If there is no caption for the current
|
|
/// [position], this will be a [Caption.none] object.
|
|
final Caption caption;
|
|
|
|
/// The [Duration] that should be used to offset the current [position] to get the correct [Caption].
|
|
///
|
|
/// Defaults to Duration.zero.
|
|
final Duration captionOffset;
|
|
|
|
/// The currently buffered ranges.
|
|
final List<platform_interface.DurationRange> buffered;
|
|
|
|
/// True if the video is playing. False if it's paused.
|
|
final bool isPlaying;
|
|
|
|
/// True if the video is looping.
|
|
final bool isLooping;
|
|
|
|
/// True if the video is currently buffering.
|
|
final bool isBuffering;
|
|
|
|
/// The current volume of the playback.
|
|
final double volume;
|
|
|
|
/// The current speed of the playback.
|
|
final double playbackSpeed;
|
|
|
|
/// A description of the error if present.
|
|
///
|
|
/// If [hasError] is false this is `null`.
|
|
final String? errorDescription;
|
|
|
|
/// True if video has finished playing to end.
|
|
///
|
|
/// Reverts to false if video position changes, or video begins playing.
|
|
/// Does not update if video is looping.
|
|
final bool isCompleted;
|
|
|
|
/// Whether the screen is prevented from sleeping during video playback.
|
|
///
|
|
/// Defaults to `true`.
|
|
///
|
|
/// This is currently only supported on iOS and macOS.
|
|
final bool preventsDisplaySleepDuringVideoPlayback;
|
|
|
|
/// The [size] of the currently loaded video.
|
|
final Size size;
|
|
|
|
/// Degrees to rotate the video (clockwise) so it is displayed correctly.
|
|
final int rotationCorrection;
|
|
|
|
/// Indicates whether or not the video has been loaded and is ready to play.
|
|
final bool isInitialized;
|
|
|
|
/// Indicates whether or not the video is in an error state. If this is true
|
|
/// [errorDescription] should have information about the problem.
|
|
bool get hasError => errorDescription != null;
|
|
|
|
/// Returns [size.width] / [size.height].
|
|
///
|
|
/// Will return `1.0` if:
|
|
/// * [isInitialized] is `false`
|
|
/// * [size.width], or [size.height] is equal to `0.0`
|
|
/// * aspect ratio would be less than or equal to `0.0`
|
|
double get aspectRatio {
|
|
if (!isInitialized || size.width == 0 || size.height == 0) {
|
|
return 1.0;
|
|
}
|
|
final double aspectRatio = size.width / size.height;
|
|
if (aspectRatio <= 0) {
|
|
return 1.0;
|
|
}
|
|
return aspectRatio;
|
|
}
|
|
|
|
/// Returns a new instance that has the same values as this current instance,
|
|
/// except for any overrides passed in as arguments to [copyWith].
|
|
VideoPlayerValue copyWith({
|
|
Duration? duration,
|
|
Size? size,
|
|
Duration? position,
|
|
Caption? caption,
|
|
Duration? captionOffset,
|
|
List<platform_interface.DurationRange>? buffered,
|
|
bool? isInitialized,
|
|
bool? isPlaying,
|
|
bool? isLooping,
|
|
bool? isBuffering,
|
|
double? volume,
|
|
double? playbackSpeed,
|
|
int? rotationCorrection,
|
|
String? errorDescription = _defaultErrorDescription,
|
|
bool? isCompleted,
|
|
bool? preventsDisplaySleepDuringVideoPlayback,
|
|
}) {
|
|
return VideoPlayerValue(
|
|
duration: duration ?? this.duration,
|
|
size: size ?? this.size,
|
|
position: position ?? this.position,
|
|
caption: caption ?? this.caption,
|
|
captionOffset: captionOffset ?? this.captionOffset,
|
|
buffered: buffered ?? this.buffered,
|
|
isInitialized: isInitialized ?? this.isInitialized,
|
|
isPlaying: isPlaying ?? this.isPlaying,
|
|
isLooping: isLooping ?? this.isLooping,
|
|
isBuffering: isBuffering ?? this.isBuffering,
|
|
volume: volume ?? this.volume,
|
|
playbackSpeed: playbackSpeed ?? this.playbackSpeed,
|
|
rotationCorrection: rotationCorrection ?? this.rotationCorrection,
|
|
errorDescription: errorDescription != _defaultErrorDescription
|
|
? errorDescription
|
|
: this.errorDescription,
|
|
isCompleted: isCompleted ?? this.isCompleted,
|
|
preventsDisplaySleepDuringVideoPlayback:
|
|
preventsDisplaySleepDuringVideoPlayback ?? this.preventsDisplaySleepDuringVideoPlayback,
|
|
);
|
|
}
|
|
|
|
@override
|
|
String toString() {
|
|
return '${objectRuntimeType(this, 'VideoPlayerValue')}('
|
|
'duration: $duration, '
|
|
'size: $size, '
|
|
'position: $position, '
|
|
'caption: $caption, '
|
|
'captionOffset: $captionOffset, '
|
|
'buffered: [${buffered.join(', ')}], '
|
|
'isInitialized: $isInitialized, '
|
|
'isPlaying: $isPlaying, '
|
|
'isLooping: $isLooping, '
|
|
'isBuffering: $isBuffering, '
|
|
'volume: $volume, '
|
|
'playbackSpeed: $playbackSpeed, '
|
|
'errorDescription: $errorDescription, '
|
|
'isCompleted: $isCompleted, '
|
|
'preventsDisplaySleepDuringVideoPlayback: $preventsDisplaySleepDuringVideoPlayback),';
|
|
}
|
|
|
|
@override
|
|
bool operator ==(Object other) =>
|
|
identical(this, other) ||
|
|
other is VideoPlayerValue &&
|
|
runtimeType == other.runtimeType &&
|
|
duration == other.duration &&
|
|
position == other.position &&
|
|
caption == other.caption &&
|
|
captionOffset == other.captionOffset &&
|
|
listEquals(buffered, other.buffered) &&
|
|
isPlaying == other.isPlaying &&
|
|
isLooping == other.isLooping &&
|
|
isBuffering == other.isBuffering &&
|
|
volume == other.volume &&
|
|
playbackSpeed == other.playbackSpeed &&
|
|
errorDescription == other.errorDescription &&
|
|
size == other.size &&
|
|
rotationCorrection == other.rotationCorrection &&
|
|
isInitialized == other.isInitialized &&
|
|
isCompleted == other.isCompleted &&
|
|
preventsDisplaySleepDuringVideoPlayback == other.preventsDisplaySleepDuringVideoPlayback;
|
|
|
|
@override
|
|
int get hashCode => Object.hash(
|
|
duration,
|
|
position,
|
|
caption,
|
|
captionOffset,
|
|
buffered,
|
|
isPlaying,
|
|
isLooping,
|
|
isBuffering,
|
|
volume,
|
|
playbackSpeed,
|
|
errorDescription,
|
|
size,
|
|
rotationCorrection,
|
|
isInitialized,
|
|
isCompleted,
|
|
preventsDisplaySleepDuringVideoPlayback,
|
|
);
|
|
}
|
|
|
|
/// Controls a platform video player, and provides updates when the state is
|
|
/// changing.
|
|
///
|
|
/// Instances must be initialized with initialize.
|
|
///
|
|
/// The video is displayed in a Flutter app by creating a [VideoPlayer] widget.
|
|
///
|
|
/// To reclaim the resources used by the player call [dispose].
|
|
///
|
|
/// After [dispose] all further calls are ignored.
|
|
class VideoPlayerController extends ValueNotifier<VideoPlayerValue> {
|
|
/// Constructs a [VideoPlayerController] playing a video from an asset.
|
|
///
|
|
/// The name of the asset is given by the [dataSource] argument and must not be
|
|
/// null. The [package] argument must be non-null when the asset comes from a
|
|
/// package and null otherwise.
|
|
///
|
|
/// The [viewType] option allows the caller to request a specific display mode
|
|
/// for the video. Platforms that do not support the request view type will
|
|
/// ignore this parameter.
|
|
VideoPlayerController.asset(
|
|
this.dataSource, {
|
|
this.package,
|
|
Future<ClosedCaptionFile>? closedCaptionFile,
|
|
this.videoPlayerOptions,
|
|
this.viewType = platform_interface.VideoViewType.textureView,
|
|
}) : _closedCaptionFileFuture = closedCaptionFile,
|
|
dataSourceType = platform_interface.DataSourceType.asset,
|
|
formatHint = null,
|
|
httpHeaders = const <String, String>{},
|
|
super(
|
|
VideoPlayerValue(
|
|
duration: Duration.zero,
|
|
preventsDisplaySleepDuringVideoPlayback:
|
|
videoPlayerOptions?.preventsDisplaySleepDuringVideoPlayback ?? true,
|
|
),
|
|
);
|
|
|
|
/// Constructs a [VideoPlayerController] playing a network video.
|
|
///
|
|
/// The URI for the video is given by the [dataSource] argument.
|
|
///
|
|
/// **Android only**: The [formatHint] option allows the caller to override
|
|
/// the video format detection code.
|
|
///
|
|
/// The [viewType] option allows the caller to request a specific display mode
|
|
/// for the video. Platforms that do not support the request view type will
|
|
/// ignore this parameter.
|
|
///
|
|
/// [httpHeaders] option allows to specify HTTP headers
|
|
/// for the request to the [dataSource].
|
|
@Deprecated('Use VideoPlayerController.networkUrl instead')
|
|
VideoPlayerController.network(
|
|
this.dataSource, {
|
|
this.formatHint,
|
|
Future<ClosedCaptionFile>? closedCaptionFile,
|
|
this.videoPlayerOptions,
|
|
this.httpHeaders = const <String, String>{},
|
|
this.viewType = platform_interface.VideoViewType.textureView,
|
|
}) : _closedCaptionFileFuture = closedCaptionFile,
|
|
dataSourceType = platform_interface.DataSourceType.network,
|
|
package = null,
|
|
super(
|
|
VideoPlayerValue(
|
|
duration: Duration.zero,
|
|
preventsDisplaySleepDuringVideoPlayback:
|
|
videoPlayerOptions?.preventsDisplaySleepDuringVideoPlayback ?? true,
|
|
),
|
|
);
|
|
|
|
/// Constructs a [VideoPlayerController] playing a network video.
|
|
///
|
|
/// The URI for the video is given by the [dataSource] argument.
|
|
///
|
|
/// **Android only**: The [formatHint] option allows the caller to override
|
|
/// the video format detection code.
|
|
///
|
|
/// [httpHeaders] option allows to specify HTTP headers
|
|
/// for the request to the [dataSource].
|
|
VideoPlayerController.networkUrl(
|
|
Uri url, {
|
|
this.formatHint,
|
|
Future<ClosedCaptionFile>? closedCaptionFile,
|
|
this.videoPlayerOptions,
|
|
this.httpHeaders = const <String, String>{},
|
|
this.viewType = platform_interface.VideoViewType.textureView,
|
|
}) : _closedCaptionFileFuture = closedCaptionFile,
|
|
dataSource = url.toString(),
|
|
dataSourceType = platform_interface.DataSourceType.network,
|
|
package = null,
|
|
super(
|
|
VideoPlayerValue(
|
|
duration: Duration.zero,
|
|
preventsDisplaySleepDuringVideoPlayback:
|
|
videoPlayerOptions?.preventsDisplaySleepDuringVideoPlayback ?? true,
|
|
),
|
|
);
|
|
|
|
/// Constructs a [VideoPlayerController] playing a video from a file.
|
|
///
|
|
/// This will load the file from a file:// URI constructed from [file]'s path.
|
|
/// [httpHeaders] option allows to specify HTTP headers, mainly used for hls files like (m3u8).
|
|
VideoPlayerController.file(
|
|
File file, {
|
|
Future<ClosedCaptionFile>? closedCaptionFile,
|
|
this.videoPlayerOptions,
|
|
this.httpHeaders = const <String, String>{},
|
|
this.viewType = platform_interface.VideoViewType.textureView,
|
|
}) : _closedCaptionFileFuture = closedCaptionFile,
|
|
dataSource = Uri.file(file.absolute.path).toString(),
|
|
dataSourceType = platform_interface.DataSourceType.file,
|
|
package = null,
|
|
formatHint = null,
|
|
super(
|
|
VideoPlayerValue(
|
|
duration: Duration.zero,
|
|
preventsDisplaySleepDuringVideoPlayback:
|
|
videoPlayerOptions?.preventsDisplaySleepDuringVideoPlayback ?? true,
|
|
),
|
|
);
|
|
|
|
/// Constructs a [VideoPlayerController] playing a video from a contentUri.
|
|
///
|
|
/// This will load the video from the input content-URI.
|
|
/// This is supported on Android only.
|
|
VideoPlayerController.contentUri(
|
|
Uri contentUri, {
|
|
Future<ClosedCaptionFile>? closedCaptionFile,
|
|
this.videoPlayerOptions,
|
|
this.viewType = platform_interface.VideoViewType.textureView,
|
|
}) : assert(
|
|
defaultTargetPlatform == TargetPlatform.android,
|
|
'VideoPlayerController.contentUri is only supported on Android.',
|
|
),
|
|
_closedCaptionFileFuture = closedCaptionFile,
|
|
dataSource = contentUri.toString(),
|
|
dataSourceType = platform_interface.DataSourceType.contentUri,
|
|
package = null,
|
|
formatHint = null,
|
|
httpHeaders = const <String, String>{},
|
|
super(
|
|
VideoPlayerValue(
|
|
duration: Duration.zero,
|
|
preventsDisplaySleepDuringVideoPlayback:
|
|
videoPlayerOptions?.preventsDisplaySleepDuringVideoPlayback ?? true,
|
|
),
|
|
);
|
|
|
|
/// The URI to the video file. This will be in different formats depending on
|
|
/// the [DataSourceType] of the original video.
|
|
final String dataSource;
|
|
|
|
/// HTTP headers used for the request to the [dataSource].
|
|
/// Only for [VideoPlayerController.network].
|
|
/// Always empty for other video types.
|
|
final Map<String, String> httpHeaders;
|
|
|
|
/// **Android only**. Will override the platform's generic file format
|
|
/// detection with whatever is set here.
|
|
final platform_interface.VideoFormat? formatHint;
|
|
|
|
/// Describes the type of data source this [VideoPlayerController]
|
|
/// is constructed with.
|
|
final platform_interface.DataSourceType dataSourceType;
|
|
|
|
/// Provide additional configuration options (optional). Like setting the audio mode to mix
|
|
final platform_interface.VideoPlayerOptions? videoPlayerOptions;
|
|
|
|
/// Only set for [asset] videos. The package that the asset was loaded from.
|
|
final String? package;
|
|
|
|
/// The requested display mode for the video.
|
|
///
|
|
/// Platforms that do not support the request view type will ignore this.
|
|
final platform_interface.VideoViewType viewType;
|
|
|
|
Future<ClosedCaptionFile>? _closedCaptionFileFuture;
|
|
ClosedCaptionFile? _closedCaptionFile;
|
|
List<Caption>? _sortedCaptions;
|
|
|
|
Timer? _timer;
|
|
bool _isDisposed = false;
|
|
Completer<void>? _creatingCompleter;
|
|
StreamSubscription<dynamic>? _eventSubscription;
|
|
_VideoAppLifeCycleObserver? _lifeCycleObserver;
|
|
|
|
/// The id of a player that hasn't been initialized.
|
|
@visibleForTesting
|
|
static const int kUninitializedPlayerId = -1;
|
|
int _playerId = kUninitializedPlayerId;
|
|
|
|
/// This is just exposed for testing. It shouldn't be used by anyone depending
|
|
/// on the plugin.
|
|
@visibleForTesting
|
|
int get playerId => _playerId;
|
|
|
|
/// Attempts to open the given [dataSource] and load metadata about the video.
|
|
Future<void> initialize() async {
|
|
final bool allowBackgroundPlayback = videoPlayerOptions?.allowBackgroundPlayback ?? false;
|
|
if (!allowBackgroundPlayback) {
|
|
_lifeCycleObserver = _VideoAppLifeCycleObserver(this);
|
|
}
|
|
_lifeCycleObserver?.initialize();
|
|
_creatingCompleter = Completer<void>();
|
|
|
|
final platform_interface.DataSource dataSourceDescription;
|
|
switch (dataSourceType) {
|
|
case platform_interface.DataSourceType.asset:
|
|
dataSourceDescription = platform_interface.DataSource(
|
|
sourceType: platform_interface.DataSourceType.asset,
|
|
asset: dataSource,
|
|
package: package,
|
|
);
|
|
case platform_interface.DataSourceType.network:
|
|
dataSourceDescription = platform_interface.DataSource(
|
|
sourceType: platform_interface.DataSourceType.network,
|
|
uri: dataSource,
|
|
formatHint: formatHint,
|
|
httpHeaders: httpHeaders,
|
|
);
|
|
case platform_interface.DataSourceType.file:
|
|
dataSourceDescription = platform_interface.DataSource(
|
|
sourceType: platform_interface.DataSourceType.file,
|
|
uri: dataSource,
|
|
httpHeaders: httpHeaders,
|
|
);
|
|
case platform_interface.DataSourceType.contentUri:
|
|
dataSourceDescription = platform_interface.DataSource(
|
|
sourceType: platform_interface.DataSourceType.contentUri,
|
|
uri: dataSource,
|
|
);
|
|
}
|
|
|
|
final creationOptions = platform_interface.VideoCreationOptions(
|
|
dataSource: dataSourceDescription,
|
|
viewType: viewType,
|
|
videoPlayerOptions: videoPlayerOptions,
|
|
);
|
|
|
|
if (videoPlayerOptions?.mixWithOthers != null) {
|
|
await _videoPlayerPlatform.setMixWithOthers(videoPlayerOptions!.mixWithOthers);
|
|
}
|
|
|
|
_playerId =
|
|
(await _videoPlayerPlatform.createWithOptions(creationOptions)) ?? kUninitializedPlayerId;
|
|
_creatingCompleter!.complete(null);
|
|
final initializingCompleter = Completer<void>();
|
|
|
|
await _videoPlayerPlatform.setPreventsDisplaySleepDuringVideoPlayback(
|
|
_playerId,
|
|
value.preventsDisplaySleepDuringVideoPlayback,
|
|
);
|
|
|
|
// Apply the web-specific options
|
|
if (kIsWeb && videoPlayerOptions?.webOptions != null) {
|
|
await _videoPlayerPlatform.setWebOptions(_playerId, videoPlayerOptions!.webOptions!);
|
|
}
|
|
|
|
void eventListener(platform_interface.VideoEvent event) {
|
|
if (_isDisposed) {
|
|
return;
|
|
}
|
|
|
|
switch (event.eventType) {
|
|
case platform_interface.VideoEventType.initialized:
|
|
value = value.copyWith(
|
|
duration: event.duration,
|
|
size: event.size,
|
|
rotationCorrection: event.rotationCorrection,
|
|
isInitialized: event.duration != null,
|
|
errorDescription: null,
|
|
isCompleted: false,
|
|
);
|
|
assert(
|
|
!initializingCompleter.isCompleted,
|
|
'VideoPlayerController already initialized. This is typically a '
|
|
'sign that an implementation of the VideoPlayerPlatform '
|
|
'(${_videoPlayerPlatform.runtimeType}) has a bug and is sending '
|
|
'more than one initialized event per instance.',
|
|
);
|
|
if (initializingCompleter.isCompleted) {
|
|
throw StateError('VideoPlayerController already initialized');
|
|
}
|
|
initializingCompleter.complete(null);
|
|
_applyLooping();
|
|
_applyVolume();
|
|
_applyPlayPause();
|
|
case platform_interface.VideoEventType.completed:
|
|
// In this case we need to stop _timer, set isPlaying=false, and
|
|
// position=value.duration. Instead of setting the values directly,
|
|
// we use pause() and seekTo() to ensure the platform stops playing
|
|
// and seeks to the last frame of the video.
|
|
pause().then((void pauseResult) => seekTo(value.duration));
|
|
value = value.copyWith(isCompleted: true);
|
|
case platform_interface.VideoEventType.bufferingUpdate:
|
|
value = value.copyWith(buffered: event.buffered);
|
|
case platform_interface.VideoEventType.bufferingStart:
|
|
value = value.copyWith(isBuffering: true);
|
|
case platform_interface.VideoEventType.bufferingEnd:
|
|
value = value.copyWith(isBuffering: false);
|
|
case platform_interface.VideoEventType.isPlayingStateUpdate:
|
|
if (event.isPlaying ?? false) {
|
|
value = value.copyWith(isPlaying: event.isPlaying, isCompleted: false);
|
|
} else {
|
|
value = value.copyWith(isPlaying: event.isPlaying);
|
|
}
|
|
case platform_interface.VideoEventType.unknown:
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (_closedCaptionFileFuture != null) {
|
|
await _updateClosedCaptionWithFuture(_closedCaptionFileFuture);
|
|
}
|
|
|
|
void errorListener(Object obj) {
|
|
final e = obj as PlatformException;
|
|
value = VideoPlayerValue.erroneous(e.message!);
|
|
_timer?.cancel();
|
|
if (!initializingCompleter.isCompleted) {
|
|
initializingCompleter.completeError(obj);
|
|
}
|
|
}
|
|
|
|
_eventSubscription = _videoPlayerPlatform
|
|
.videoEventsFor(_playerId)
|
|
.listen(eventListener, onError: errorListener);
|
|
return initializingCompleter.future;
|
|
}
|
|
|
|
@override
|
|
Future<void> dispose() async {
|
|
if (_isDisposed) {
|
|
return;
|
|
}
|
|
|
|
if (_creatingCompleter != null) {
|
|
await _creatingCompleter!.future;
|
|
if (!_isDisposed) {
|
|
_isDisposed = true;
|
|
_timer?.cancel();
|
|
await _eventSubscription?.cancel();
|
|
await _videoPlayerPlatform.dispose(_playerId);
|
|
}
|
|
_lifeCycleObserver?.dispose();
|
|
}
|
|
_isDisposed = true;
|
|
super.dispose();
|
|
}
|
|
|
|
/// Starts playing the video.
|
|
///
|
|
/// If the video is at the end, this method starts playing from the beginning.
|
|
///
|
|
/// This method returns a future that completes as soon as the "play" command
|
|
/// has been sent to the platform, not when playback itself is totally
|
|
/// finished.
|
|
Future<void> play() async {
|
|
if (value.position == value.duration) {
|
|
await seekTo(Duration.zero);
|
|
}
|
|
value = value.copyWith(isPlaying: true);
|
|
await _applyPlayPause();
|
|
}
|
|
|
|
/// Sets whether or not the video should loop after playing once. See also
|
|
/// [VideoPlayerValue.isLooping].
|
|
Future<void> setLooping(bool looping) async {
|
|
value = value.copyWith(isLooping: looping);
|
|
await _applyLooping();
|
|
}
|
|
|
|
/// Sets whether the screen is prevented from sleeping during video playback.
|
|
///
|
|
/// See also [VideoPlayerValue.preventsDisplaySleepDuringVideoPlayback].
|
|
Future<void> setPreventsDisplaySleepDuringVideoPlayback(
|
|
bool preventsDisplaySleepDuringVideoPlayback,
|
|
) async {
|
|
value = value.copyWith(
|
|
preventsDisplaySleepDuringVideoPlayback: preventsDisplaySleepDuringVideoPlayback,
|
|
);
|
|
await _applyPreventsDisplaySleepDuringVideoPlayback();
|
|
}
|
|
|
|
/// Pauses the video.
|
|
Future<void> pause() async {
|
|
value = value.copyWith(isPlaying: false);
|
|
await _applyPlayPause();
|
|
}
|
|
|
|
Future<void> _applyLooping() async {
|
|
if (_isDisposedOrNotInitialized) {
|
|
return;
|
|
}
|
|
await _videoPlayerPlatform.setLooping(_playerId, value.isLooping);
|
|
}
|
|
|
|
Future<void> _applyPreventsDisplaySleepDuringVideoPlayback() async {
|
|
if (_isDisposedOrNotInitialized) {
|
|
return;
|
|
}
|
|
await _videoPlayerPlatform.setPreventsDisplaySleepDuringVideoPlayback(
|
|
_playerId,
|
|
value.preventsDisplaySleepDuringVideoPlayback,
|
|
);
|
|
}
|
|
|
|
Future<void> _applyPlayPause() async {
|
|
if (_isDisposedOrNotInitialized) {
|
|
return;
|
|
}
|
|
if (value.isPlaying) {
|
|
await _videoPlayerPlatform.play(_playerId);
|
|
|
|
_timer?.cancel();
|
|
_timer = Timer.periodic(const Duration(milliseconds: 100), (Timer timer) async {
|
|
if (_isDisposed) {
|
|
return;
|
|
}
|
|
final Duration? newPosition = await position;
|
|
if (newPosition == null) {
|
|
return;
|
|
}
|
|
_updatePosition(newPosition);
|
|
});
|
|
|
|
// This ensures that the correct playback speed is always applied when
|
|
// playing back. This is necessary because we do not set playback speed
|
|
// when paused.
|
|
await _applyPlaybackSpeed();
|
|
} else {
|
|
_timer?.cancel();
|
|
await _videoPlayerPlatform.pause(_playerId);
|
|
}
|
|
}
|
|
|
|
Future<void> _applyVolume() async {
|
|
if (_isDisposedOrNotInitialized) {
|
|
return;
|
|
}
|
|
await _videoPlayerPlatform.setVolume(_playerId, value.volume);
|
|
}
|
|
|
|
Future<void> _applyPlaybackSpeed() async {
|
|
if (_isDisposedOrNotInitialized) {
|
|
return;
|
|
}
|
|
|
|
// Setting the playback speed on iOS will trigger the video to play. We
|
|
// prevent this from happening by not applying the playback speed until
|
|
// the video is manually played from Flutter.
|
|
if (!value.isPlaying) {
|
|
return;
|
|
}
|
|
|
|
await _videoPlayerPlatform.setPlaybackSpeed(_playerId, value.playbackSpeed);
|
|
}
|
|
|
|
/// The position in the current video.
|
|
Future<Duration?> get position async {
|
|
if (_isDisposed) {
|
|
return null;
|
|
}
|
|
return _videoPlayerPlatform.getPosition(_playerId);
|
|
}
|
|
|
|
/// Sets the video's current timestamp to be at [moment]. The next
|
|
/// time the video is played it will resume from the given [moment].
|
|
///
|
|
/// If [moment] is outside of the video's full range it will be automatically
|
|
/// and silently clamped.
|
|
Future<void> seekTo(Duration position) async {
|
|
if (_isDisposedOrNotInitialized) {
|
|
return;
|
|
}
|
|
if (position > value.duration) {
|
|
position = value.duration;
|
|
} else if (position < Duration.zero) {
|
|
position = Duration.zero;
|
|
}
|
|
await _videoPlayerPlatform.seekTo(_playerId, position);
|
|
_updatePosition(position);
|
|
}
|
|
|
|
/// Sets the audio volume of [this].
|
|
///
|
|
/// [volume] indicates a value between 0.0 (silent) and 1.0 (full volume) on a
|
|
/// linear scale.
|
|
Future<void> setVolume(double volume) async {
|
|
value = value.copyWith(volume: volume.clamp(0.0, 1.0));
|
|
await _applyVolume();
|
|
}
|
|
|
|
/// Sets the playback speed of [this].
|
|
///
|
|
/// [speed] indicates a speed value with different platforms accepting
|
|
/// different ranges for speed values. The [speed] must be greater than 0.
|
|
///
|
|
/// The values will be handled as follows:
|
|
/// * On web, the audio will be muted at some speed when the browser
|
|
/// determines that the sound would not be useful anymore. For example,
|
|
/// "Gecko mutes the sound outside the range `0.25` to `5.0`" (see https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/playbackRate).
|
|
/// * On Android, some very extreme speeds will not be played back accurately.
|
|
/// Instead, your video will still be played back, but the speed will be
|
|
/// clamped by ExoPlayer (but the values are allowed by the player, like on
|
|
/// web).
|
|
/// * On iOS, you can sometimes not go above `2.0` playback speed on a video.
|
|
/// An error will be thrown for if the option is unsupported. It is also
|
|
/// possible that your specific video cannot be slowed down, in which case
|
|
/// the plugin also reports errors.
|
|
Future<void> setPlaybackSpeed(double speed) async {
|
|
if (speed < 0) {
|
|
throw ArgumentError.value(speed, 'Negative playback speeds are generally unsupported.');
|
|
} else if (speed == 0) {
|
|
throw ArgumentError.value(
|
|
speed,
|
|
'Zero playback speed is generally unsupported. Consider using [pause].',
|
|
);
|
|
}
|
|
|
|
value = value.copyWith(playbackSpeed: speed);
|
|
await _applyPlaybackSpeed();
|
|
}
|
|
|
|
/// Sets the caption offset.
|
|
///
|
|
/// The [offset] will be used when getting the correct caption for a specific position.
|
|
/// The [offset] can be positive or negative.
|
|
///
|
|
/// The values will be handled as follows:
|
|
/// * 0: This is the default behaviour. No offset will be applied.
|
|
/// * >0: The caption will have a negative offset. So you will get caption text from the past.
|
|
/// * <0: The caption will have a positive offset. So you will get caption text from the future.
|
|
void setCaptionOffset(Duration offset) {
|
|
value = value.copyWith(captionOffset: offset, caption: _getCaptionAt(value.position));
|
|
}
|
|
|
|
/// The closed caption based on the current [position] in the video.
|
|
///
|
|
/// If there are no closed captions at the current [position], this will
|
|
/// return an empty [Caption].
|
|
///
|
|
/// If no [closedCaptionFile] was specified, this will always return an empty
|
|
/// [Caption].
|
|
|
|
Caption _getCaptionAt(Duration position) {
|
|
final List<Caption>? sortedCaptions = _sortedCaptions;
|
|
if (_closedCaptionFile == null || sortedCaptions == null) {
|
|
return Caption.none;
|
|
}
|
|
|
|
final Duration delayedPosition = position + value.captionOffset;
|
|
|
|
final int captionIndex = collection.binarySearch<Caption>(
|
|
sortedCaptions,
|
|
Caption(number: -1, start: delayedPosition, end: delayedPosition, text: ''),
|
|
compare: (Caption candidate, Caption search) {
|
|
if (search.start < candidate.start) {
|
|
return 1;
|
|
} else if (search.start > candidate.end) {
|
|
return -1;
|
|
} else {
|
|
// delayedPosition is within [candidate.start, candidate.end]
|
|
return 0;
|
|
}
|
|
},
|
|
);
|
|
|
|
// -1 means not found by the binary search.
|
|
if (captionIndex == -1) {
|
|
return Caption.none;
|
|
}
|
|
|
|
return sortedCaptions[captionIndex];
|
|
}
|
|
|
|
/// Returns the file containing closed captions for the video, if any.
|
|
Future<ClosedCaptionFile>? get closedCaptionFile {
|
|
return _closedCaptionFileFuture;
|
|
}
|
|
|
|
/// Sets a closed caption file.
|
|
///
|
|
/// If [closedCaptionFile] is null, closed captions will be removed.
|
|
Future<void> setClosedCaptionFile(Future<ClosedCaptionFile>? closedCaptionFile) async {
|
|
_closedCaptionFileFuture = closedCaptionFile;
|
|
// Reset sorted captions to force re-sort when setting a new file
|
|
_sortedCaptions = null;
|
|
await _updateClosedCaptionWithFuture(closedCaptionFile);
|
|
}
|
|
|
|
Future<void> _updateClosedCaptionWithFuture(Future<ClosedCaptionFile>? closedCaptionFile) async {
|
|
if (closedCaptionFile != null) {
|
|
_closedCaptionFile = await closedCaptionFile;
|
|
|
|
// Only sort if we haven't sorted yet (first initialization)
|
|
_sortedCaptions ??= List<Caption>.from(_closedCaptionFile!.captions)
|
|
..sort((Caption a, Caption b) {
|
|
return a.start.compareTo(b.start);
|
|
});
|
|
|
|
value = value.copyWith(caption: _getCaptionAt(value.position));
|
|
} else {
|
|
_closedCaptionFile = null;
|
|
_sortedCaptions = null;
|
|
value = value.copyWith(caption: Caption.none);
|
|
}
|
|
}
|
|
|
|
void _updatePosition(Duration position) {
|
|
// The underlying native implementation on some platforms sometimes reports
|
|
// a position slightly past the reported max duration. Clamp to the duration
|
|
// to insulate clients from this behavior.
|
|
if (position > value.duration) {
|
|
position = value.duration;
|
|
}
|
|
value = value.copyWith(
|
|
position: position,
|
|
caption: _getCaptionAt(position),
|
|
isCompleted: position == value.duration,
|
|
);
|
|
}
|
|
|
|
@override
|
|
void removeListener(VoidCallback listener) {
|
|
// Prevent VideoPlayer from causing an exception to be thrown when attempting to
|
|
// remove its own listener after the controller has already been disposed.
|
|
if (!_isDisposed) {
|
|
super.removeListener(listener);
|
|
}
|
|
}
|
|
|
|
/// Gets the available audio tracks for the video.
|
|
///
|
|
/// Returns a list of [VideoAudioTrack] objects containing metadata about
|
|
/// each available audio track. The list may be empty if no audio tracks
|
|
/// are available or if the video is not initialized.
|
|
///
|
|
/// Throws an error if the video player is disposed.
|
|
Future<List<VideoAudioTrack>> getAudioTracks() async {
|
|
if (_isDisposed) {
|
|
throw StateError('VideoPlayerController is disposed');
|
|
}
|
|
if (!value.isInitialized) {
|
|
return <VideoAudioTrack>[];
|
|
}
|
|
final List<platform_interface.VideoAudioTrack> platformTracks = await _videoPlayerPlatform
|
|
.getAudioTracks(_playerId);
|
|
return platformTracks.map(_convertPlatformAudioTrack).toList();
|
|
}
|
|
|
|
/// Selects which audio track is chosen for playback from its [trackId]
|
|
///
|
|
/// The [trackId] should match the ID of one of the tracks returned by
|
|
/// [getAudioTracks]. If the track ID is not found or invalid, the
|
|
/// platform may ignore the request or throw an exception.
|
|
///
|
|
/// Throws an error if the video player is disposed or not initialized.
|
|
Future<void> selectAudioTrack(String trackId) async {
|
|
if (_isDisposedOrNotInitialized) {
|
|
throw StateError('VideoPlayerController is disposed or not initialized');
|
|
}
|
|
// The platform implementation (e.g., Android) will wait for the track
|
|
// selection to complete by listening to platform-specific events
|
|
await _videoPlayerPlatform.selectAudioTrack(_playerId, trackId);
|
|
}
|
|
|
|
/// Returns whether audio track selection is supported on this platform.
|
|
///
|
|
/// This method allows developers to query at runtime whether the current
|
|
/// platform supports audio track selection functionality. This is useful
|
|
/// for platforms like web where audio track selection may not be available.
|
|
///
|
|
/// Returns `true` if [getAudioTracks] and [selectAudioTrack] are supported,
|
|
/// `false` otherwise.
|
|
///
|
|
/// Example usage:
|
|
/// ```dart
|
|
/// if (controller.isAudioTrackSupportAvailable()) {
|
|
/// final tracks = await controller.getAudioTracks();
|
|
/// // Show audio track selection UI
|
|
/// } else {
|
|
/// // Hide audio track selection UI or show unsupported message
|
|
/// }
|
|
/// ```
|
|
bool isAudioTrackSupportAvailable() {
|
|
return _videoPlayerPlatform.isAudioTrackSupportAvailable();
|
|
}
|
|
|
|
bool get _isDisposedOrNotInitialized => _isDisposed || !value.isInitialized;
|
|
|
|
/// Gets the available video tracks for the video.
|
|
///
|
|
/// The returned list contains a [VideoTrack] for each track available
|
|
/// for selection.
|
|
///
|
|
/// For adaptive streams such as HLS or DASH, these often correspond to
|
|
/// different quality levels with different resolutions or bitrates.
|
|
/// For non-adaptive videos (MP4, MOV, etc.), platform implementations may
|
|
/// return one or more tracks, or an empty list, depending on the asset and
|
|
/// the metadata available.
|
|
///
|
|
/// Note: On iOS 13-14, this returns an empty list as the AVAssetVariant API
|
|
/// requires iOS 15+. On web, this throws an [UnimplementedError].
|
|
///
|
|
/// Check [isVideoTrackSupportAvailable] before calling this method to ensure
|
|
/// the platform supports video track selection.
|
|
Future<List<VideoTrack>> getVideoTracks() async {
|
|
if (_isDisposedOrNotInitialized) {
|
|
return <VideoTrack>[];
|
|
}
|
|
final List<platform_interface.VideoTrack> platformTracks = await _videoPlayerPlatform
|
|
.getVideoTracks(_playerId);
|
|
return platformTracks
|
|
.map((platform_interface.VideoTrack track) => VideoTrack._fromPlatform(track))
|
|
.toList();
|
|
}
|
|
|
|
/// Selects which video track is chosen for playback.
|
|
///
|
|
/// Pass a [VideoTrack] to select a specific track.
|
|
/// Pass `null` to clear any manual selection and allow automatic selection.
|
|
///
|
|
/// On iOS, this sets `preferredPeakBitRate` on the AVPlayerItem.
|
|
/// On Android, this uses ExoPlayer's track selection override.
|
|
/// On web, this throws an [UnimplementedError].
|
|
///
|
|
/// Check [isVideoTrackSupportAvailable] before calling this method to ensure
|
|
/// the platform supports video track selection.
|
|
Future<void> selectVideoTrack(VideoTrack? track) async {
|
|
if (_isDisposedOrNotInitialized) {
|
|
return;
|
|
}
|
|
// Convert app-facing VideoTrack to platform interface VideoTrack
|
|
final platform_interface.VideoTrack? platformTrack = track != null
|
|
? platform_interface.VideoTrack(
|
|
id: track.id,
|
|
isSelected: track.isSelected,
|
|
label: track.label,
|
|
bitrate: track.bitrate,
|
|
width: track.width,
|
|
height: track.height,
|
|
frameRate: track.frameRate,
|
|
codec: track.codec,
|
|
)
|
|
: null;
|
|
await _videoPlayerPlatform.selectVideoTrack(_playerId, platformTrack);
|
|
}
|
|
|
|
/// Whether video track selection is supported on this platform.
|
|
///
|
|
/// Use this to check before calling [getVideoTracks] or [selectVideoTrack]
|
|
/// to avoid [UnimplementedError] exceptions on unsupported platforms.
|
|
bool isVideoTrackSupportAvailable() {
|
|
return _videoPlayerPlatform.isVideoTrackSupportAvailable();
|
|
}
|
|
}
|
|
|
|
class _VideoAppLifeCycleObserver extends Object with WidgetsBindingObserver {
|
|
_VideoAppLifeCycleObserver(this._controller);
|
|
|
|
bool _wasPlayingBeforePause = false;
|
|
final VideoPlayerController _controller;
|
|
|
|
void initialize() {
|
|
WidgetsBinding.instance.addObserver(this);
|
|
}
|
|
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
if (state == AppLifecycleState.paused) {
|
|
_wasPlayingBeforePause = _controller.value.isPlaying;
|
|
_controller.pause();
|
|
} else if (state == AppLifecycleState.resumed) {
|
|
if (_wasPlayingBeforePause) {
|
|
_controller.play();
|
|
}
|
|
}
|
|
}
|
|
|
|
void dispose() {
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
}
|
|
}
|
|
|
|
/// Widget that displays the video controlled by [controller].
|
|
class VideoPlayer extends StatefulWidget {
|
|
/// Uses the given [controller] for all video rendered in this widget.
|
|
const VideoPlayer(this.controller, {super.key});
|
|
|
|
/// The [VideoPlayerController] responsible for the video being rendered in
|
|
/// this widget.
|
|
final VideoPlayerController controller;
|
|
|
|
@override
|
|
State<VideoPlayer> createState() => _VideoPlayerState();
|
|
}
|
|
|
|
class _VideoPlayerState extends State<VideoPlayer> {
|
|
late int _playerId;
|
|
void _controllerDidUpdateValue() {
|
|
final int newPlayerId = widget.controller.playerId;
|
|
if (newPlayerId != _playerId) {
|
|
setState(() {
|
|
_playerId = newPlayerId;
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_playerId = widget.controller.playerId;
|
|
// Need to listen for initialization events since the actual widget ID
|
|
// becomes available after asynchronous initialization finishes.
|
|
widget.controller.addListener(_controllerDidUpdateValue);
|
|
}
|
|
|
|
@override
|
|
void didUpdateWidget(VideoPlayer oldWidget) {
|
|
super.didUpdateWidget(oldWidget);
|
|
oldWidget.controller.removeListener(_controllerDidUpdateValue);
|
|
_playerId = widget.controller.playerId;
|
|
widget.controller.addListener(_controllerDidUpdateValue);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
widget.controller.removeListener(_controllerDidUpdateValue);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _playerId == VideoPlayerController.kUninitializedPlayerId
|
|
? Container()
|
|
: _VideoPlayerWithRotation(
|
|
rotation: widget.controller.value.rotationCorrection,
|
|
child: _videoPlayerPlatform.buildViewWithOptions(
|
|
platform_interface.VideoViewOptions(playerId: _playerId),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _VideoPlayerWithRotation extends StatelessWidget {
|
|
const _VideoPlayerWithRotation({required this.rotation, required this.child})
|
|
: assert(rotation % 90 == 0, 'Rotation must be a multiple of 90');
|
|
|
|
final int rotation;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (rotation == 0) {
|
|
return child;
|
|
}
|
|
return RotatedBox(quarterTurns: rotation ~/ 90, child: child);
|
|
}
|
|
}
|
|
|
|
/// Used to configure the [VideoProgressIndicator] widget's colors for how it
|
|
/// describes the video's status.
|
|
///
|
|
/// The widget uses default colors that are customizable through this class.
|
|
class VideoProgressColors {
|
|
/// Any property can be set to any color. They each have defaults.
|
|
///
|
|
/// [playedColor] defaults to red at 70% opacity. This fills up a portion of
|
|
/// the [VideoProgressIndicator] to represent how much of the video has played
|
|
/// so far.
|
|
///
|
|
/// [bufferedColor] defaults to blue at 20% opacity. This fills up a portion
|
|
/// of [VideoProgressIndicator] to represent how much of the video has
|
|
/// buffered so far.
|
|
///
|
|
/// [backgroundColor] defaults to gray at 50% opacity. This is the background
|
|
/// color behind both [playedColor] and [bufferedColor] to denote the total
|
|
/// size of the video compared to either of those values.
|
|
const VideoProgressColors({
|
|
this.playedColor = const Color.fromRGBO(255, 0, 0, 0.7),
|
|
this.bufferedColor = const Color.fromRGBO(50, 50, 200, 0.2),
|
|
this.backgroundColor = const Color.fromRGBO(200, 200, 200, 0.5),
|
|
});
|
|
|
|
/// [playedColor] defaults to red at 70% opacity. This fills up a portion of
|
|
/// the [VideoProgressIndicator] to represent how much of the video has played
|
|
/// so far.
|
|
final Color playedColor;
|
|
|
|
/// [bufferedColor] defaults to blue at 20% opacity. This fills up a portion
|
|
/// of [VideoProgressIndicator] to represent how much of the video has
|
|
/// buffered so far.
|
|
final Color bufferedColor;
|
|
|
|
/// [backgroundColor] defaults to gray at 50% opacity. This is the background
|
|
/// color behind both [playedColor] and [bufferedColor] to denote the total
|
|
/// size of the video compared to either of those values.
|
|
final Color backgroundColor;
|
|
}
|
|
|
|
/// A scrubber to control [VideoPlayerController]s
|
|
class VideoScrubber extends StatefulWidget {
|
|
/// Create a [VideoScrubber] handler with the given [child].
|
|
///
|
|
/// [controller] is the [VideoPlayerController] that will be controlled by
|
|
/// this scrubber.
|
|
const VideoScrubber({super.key, required this.child, required this.controller});
|
|
|
|
/// The widget that will be displayed inside the gesture detector.
|
|
final Widget child;
|
|
|
|
/// The [VideoPlayerController] that will be controlled by this scrubber.
|
|
final VideoPlayerController controller;
|
|
|
|
@override
|
|
State<VideoScrubber> createState() => _VideoScrubberState();
|
|
}
|
|
|
|
class _VideoScrubberState extends State<VideoScrubber> {
|
|
bool _controllerWasPlaying = false;
|
|
|
|
VideoPlayerController get controller => widget.controller;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
void seekToRelativePosition(Offset globalPosition) {
|
|
final box = context.findRenderObject()! as RenderBox;
|
|
final Offset tapPos = box.globalToLocal(globalPosition);
|
|
final double relative = tapPos.dx / box.size.width;
|
|
final Duration position = controller.value.duration * relative;
|
|
controller.seekTo(position);
|
|
}
|
|
|
|
return GestureDetector(
|
|
behavior: HitTestBehavior.opaque,
|
|
child: widget.child,
|
|
onHorizontalDragStart: (DragStartDetails details) {
|
|
if (!controller.value.isInitialized) {
|
|
return;
|
|
}
|
|
_controllerWasPlaying = controller.value.isPlaying;
|
|
if (_controllerWasPlaying) {
|
|
controller.pause();
|
|
}
|
|
},
|
|
onHorizontalDragUpdate: (DragUpdateDetails details) {
|
|
if (!controller.value.isInitialized) {
|
|
return;
|
|
}
|
|
seekToRelativePosition(details.globalPosition);
|
|
},
|
|
onHorizontalDragEnd: (DragEndDetails details) {
|
|
if (_controllerWasPlaying && controller.value.position != controller.value.duration) {
|
|
controller.play();
|
|
}
|
|
},
|
|
onTapDown: (TapDownDetails details) {
|
|
if (!controller.value.isInitialized) {
|
|
return;
|
|
}
|
|
seekToRelativePosition(details.globalPosition);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Displays the play/buffering status of the video controlled by [controller].
|
|
///
|
|
/// If [allowScrubbing] is true, this widget will detect taps and drags and
|
|
/// seek the video accordingly.
|
|
///
|
|
/// [padding] allows to specify some extra padding around the progress indicator
|
|
/// that will also detect the gestures.
|
|
class VideoProgressIndicator extends StatefulWidget {
|
|
/// Construct an instance that displays the play/buffering status of the video
|
|
/// controlled by [controller].
|
|
///
|
|
/// Defaults will be used for everything except [controller] if they're not
|
|
/// provided. [allowScrubbing] defaults to false, and [padding] will default
|
|
/// to `top: 5.0`.
|
|
const VideoProgressIndicator(
|
|
this.controller, {
|
|
super.key,
|
|
this.colors = const VideoProgressColors(),
|
|
required this.allowScrubbing,
|
|
this.padding = const EdgeInsets.only(top: 5.0),
|
|
});
|
|
|
|
/// The [VideoPlayerController] that actually associates a video with this
|
|
/// widget.
|
|
final VideoPlayerController controller;
|
|
|
|
/// The default colors used throughout the indicator.
|
|
///
|
|
/// See [VideoProgressColors] for default values.
|
|
final VideoProgressColors colors;
|
|
|
|
/// When true, the widget will detect touch input and try to seek the video
|
|
/// accordingly. The widget ignores such input when false.
|
|
///
|
|
/// Defaults to false.
|
|
final bool allowScrubbing;
|
|
|
|
/// This allows for visual padding around the progress indicator that can
|
|
/// still detect gestures via [allowScrubbing].
|
|
///
|
|
/// Defaults to `top: 5.0`.
|
|
final EdgeInsets padding;
|
|
|
|
@override
|
|
State<VideoProgressIndicator> createState() => _VideoProgressIndicatorState();
|
|
}
|
|
|
|
class _VideoProgressIndicatorState extends State<VideoProgressIndicator> {
|
|
VideoPlayerController get controller => widget.controller;
|
|
|
|
VideoProgressColors get colors => widget.colors;
|
|
|
|
void _didUpdateControllerValue() {
|
|
setState(() {
|
|
// The build method reads from controller.value.
|
|
});
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
controller.addListener(_didUpdateControllerValue);
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
controller.removeListener(_didUpdateControllerValue);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final Widget progressIndicator;
|
|
if (controller.value.isInitialized) {
|
|
final int duration = controller.value.duration.inMilliseconds;
|
|
final int position = controller.value.position.inMilliseconds;
|
|
|
|
final double maxBuffering = duration == 0.0
|
|
? 0.0
|
|
: controller.value.buffered
|
|
.map((platform_interface.DurationRange range) => range.end.inMilliseconds)
|
|
.fold(0, math.max) /
|
|
duration;
|
|
progressIndicator = Stack(
|
|
fit: StackFit.passthrough,
|
|
children: <Widget>[
|
|
LinearProgressIndicator(
|
|
value: maxBuffering,
|
|
valueColor: AlwaysStoppedAnimation<Color>(colors.bufferedColor),
|
|
backgroundColor: colors.backgroundColor,
|
|
),
|
|
LinearProgressIndicator(
|
|
value: duration == 0.0 ? 0.0 : position / duration,
|
|
valueColor: AlwaysStoppedAnimation<Color>(colors.playedColor),
|
|
backgroundColor: Colors.transparent,
|
|
),
|
|
],
|
|
);
|
|
} else {
|
|
progressIndicator = LinearProgressIndicator(
|
|
valueColor: AlwaysStoppedAnimation<Color>(colors.playedColor),
|
|
backgroundColor: colors.backgroundColor,
|
|
);
|
|
}
|
|
final Widget paddedProgressIndicator = Padding(
|
|
padding: widget.padding,
|
|
child: progressIndicator,
|
|
);
|
|
if (widget.allowScrubbing) {
|
|
return VideoScrubber(controller: controller, child: paddedProgressIndicator);
|
|
} else {
|
|
return paddedProgressIndicator;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Widget for displaying closed captions on top of a video.
|
|
///
|
|
/// If [text] is null, this widget will not display anything.
|
|
///
|
|
/// If [textStyle] is supplied, it will be used to style the text in the closed
|
|
/// caption.
|
|
///
|
|
/// Note: in order to have closed captions, you need to specify a
|
|
/// [VideoPlayerController.closedCaptionFile].
|
|
///
|
|
/// Usage:
|
|
///
|
|
/// ```dart
|
|
/// Stack(children: <Widget>[
|
|
/// VideoPlayer(_controller),
|
|
/// ClosedCaption(text: _controller.value.caption.text),
|
|
/// ]),
|
|
/// ```
|
|
class ClosedCaption extends StatelessWidget {
|
|
/// Creates a a new closed caption, designed to be used with
|
|
/// [VideoPlayerValue.caption].
|
|
///
|
|
/// If [text] is null or empty, nothing will be displayed.
|
|
const ClosedCaption({super.key, this.text, this.textStyle});
|
|
|
|
/// The text that will be shown in the closed caption, or null if no caption
|
|
/// should be shown.
|
|
/// If the text is empty the caption will not be shown.
|
|
final String? text;
|
|
|
|
/// Specifies how the text in the closed caption should look.
|
|
///
|
|
/// If null, defaults to [DefaultTextStyle.of(context).style] with size 36
|
|
/// font colored white.
|
|
final TextStyle? textStyle;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final String? text = this.text;
|
|
if (text == null || text.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
|
|
final TextStyle effectiveTextStyle =
|
|
textStyle ??
|
|
DefaultTextStyle.of(context).style.copyWith(fontSize: 36.0, color: Colors.white);
|
|
|
|
return Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: Padding(
|
|
padding: const EdgeInsets.only(bottom: 24.0),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xB8000000),
|
|
borderRadius: BorderRadius.circular(2.0),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 2.0),
|
|
child: Text(text, style: effectiveTextStyle),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Represents a video track in a video with its metadata.
|
|
///
|
|
/// For HLS/DASH adaptive streams, each [VideoTrack] represents a different
|
|
/// quality level (e.g., 1080p, 720p, 480p). For non-adaptive videos, platform
|
|
/// implementations may return a single track or no tracks, depending on the
|
|
/// metadata that is available.
|
|
@immutable
|
|
class VideoTrack {
|
|
/// Constructs an instance of [VideoTrack].
|
|
const VideoTrack({
|
|
required this.id,
|
|
required this.isSelected,
|
|
this.label,
|
|
this.bitrate,
|
|
this.width,
|
|
this.height,
|
|
this.frameRate,
|
|
this.codec,
|
|
});
|
|
|
|
/// Creates a [VideoTrack] from a platform interface [VideoTrack].
|
|
factory VideoTrack._fromPlatform(platform_interface.VideoTrack track) {
|
|
return VideoTrack(
|
|
id: track.id,
|
|
isSelected: track.isSelected,
|
|
label: track.label,
|
|
bitrate: track.bitrate,
|
|
width: track.width,
|
|
height: track.height,
|
|
frameRate: track.frameRate,
|
|
codec: track.codec,
|
|
);
|
|
}
|
|
|
|
/// Unique identifier for the video track.
|
|
///
|
|
/// The format is platform-specific:
|
|
/// - Android: `"{groupIndex}_{trackIndex}"` (e.g., `"0_2"`)
|
|
/// - iOS: `"variant_{bitrate}"` for HLS adaptive streams
|
|
final String id;
|
|
|
|
/// Whether this track is currently selected.
|
|
final bool isSelected;
|
|
|
|
/// Human-readable label for the track (e.g., "1080p", "720p").
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final String? label;
|
|
|
|
/// Bitrate of the video track in bits per second.
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final int? bitrate;
|
|
|
|
/// Video width in pixels.
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final int? width;
|
|
|
|
/// Video height in pixels.
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final int? height;
|
|
|
|
/// Frame rate in frames per second.
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final double? frameRate;
|
|
|
|
/// Video codec used (e.g., "avc1", "hevc", "vp9").
|
|
///
|
|
/// May be null if not available from the platform.
|
|
final String? codec;
|
|
|
|
@override
|
|
bool operator ==(Object other) {
|
|
return identical(this, other) ||
|
|
other is VideoTrack &&
|
|
runtimeType == other.runtimeType &&
|
|
id == other.id &&
|
|
isSelected == other.isSelected &&
|
|
label == other.label &&
|
|
bitrate == other.bitrate &&
|
|
width == other.width &&
|
|
height == other.height &&
|
|
frameRate == other.frameRate &&
|
|
codec == other.codec;
|
|
}
|
|
|
|
@override
|
|
int get hashCode => Object.hash(id, isSelected, label, bitrate, width, height, frameRate, codec);
|
|
|
|
@override
|
|
String toString() =>
|
|
'VideoTrack('
|
|
'id: $id, '
|
|
'isSelected: $isSelected, '
|
|
'label: $label, '
|
|
'bitrate: $bitrate, '
|
|
'width: $width, '
|
|
'height: $height, '
|
|
'frameRate: $frameRate, '
|
|
'codec: $codec)';
|
|
}
|