twonly-app-dependencies/video_player/lib/video_player.dart
2026-08-22 20:30:14 +02:00

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)';
}