// 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 [], 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 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? 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 { /// 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, this.videoPlayerOptions, this.viewType = platform_interface.VideoViewType.textureView, }) : _closedCaptionFileFuture = closedCaptionFile, dataSourceType = platform_interface.DataSourceType.asset, formatHint = null, httpHeaders = const {}, 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, this.videoPlayerOptions, this.httpHeaders = const {}, 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, this.videoPlayerOptions, this.httpHeaders = const {}, 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, this.videoPlayerOptions, this.httpHeaders = const {}, 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, 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 {}, 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 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? _closedCaptionFileFuture; ClosedCaptionFile? _closedCaptionFile; List? _sortedCaptions; Timer? _timer; bool _isDisposed = false; Completer? _creatingCompleter; StreamSubscription? _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 initialize() async { final bool allowBackgroundPlayback = videoPlayerOptions?.allowBackgroundPlayback ?? false; if (!allowBackgroundPlayback) { _lifeCycleObserver = _VideoAppLifeCycleObserver(this); } _lifeCycleObserver?.initialize(); _creatingCompleter = Completer(); 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(); 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 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 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 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 setPreventsDisplaySleepDuringVideoPlayback( bool preventsDisplaySleepDuringVideoPlayback, ) async { value = value.copyWith( preventsDisplaySleepDuringVideoPlayback: preventsDisplaySleepDuringVideoPlayback, ); await _applyPreventsDisplaySleepDuringVideoPlayback(); } /// Pauses the video. Future pause() async { value = value.copyWith(isPlaying: false); await _applyPlayPause(); } Future _applyLooping() async { if (_isDisposedOrNotInitialized) { return; } await _videoPlayerPlatform.setLooping(_playerId, value.isLooping); } Future _applyPreventsDisplaySleepDuringVideoPlayback() async { if (_isDisposedOrNotInitialized) { return; } await _videoPlayerPlatform.setPreventsDisplaySleepDuringVideoPlayback( _playerId, value.preventsDisplaySleepDuringVideoPlayback, ); } Future _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 _applyVolume() async { if (_isDisposedOrNotInitialized) { return; } await _videoPlayerPlatform.setVolume(_playerId, value.volume); } Future _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 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 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 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 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? sortedCaptions = _sortedCaptions; if (_closedCaptionFile == null || sortedCaptions == null) { return Caption.none; } final Duration delayedPosition = position + value.captionOffset; final int captionIndex = collection.binarySearch( 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? get closedCaptionFile { return _closedCaptionFileFuture; } /// Sets a closed caption file. /// /// If [closedCaptionFile] is null, closed captions will be removed. Future setClosedCaptionFile(Future? closedCaptionFile) async { _closedCaptionFileFuture = closedCaptionFile; // Reset sorted captions to force re-sort when setting a new file _sortedCaptions = null; await _updateClosedCaptionWithFuture(closedCaptionFile); } Future _updateClosedCaptionWithFuture(Future? closedCaptionFile) async { if (closedCaptionFile != null) { _closedCaptionFile = await closedCaptionFile; // Only sort if we haven't sorted yet (first initialization) _sortedCaptions ??= List.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> getAudioTracks() async { if (_isDisposed) { throw StateError('VideoPlayerController is disposed'); } if (!value.isInitialized) { return []; } final List 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 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> getVideoTracks() async { if (_isDisposedOrNotInitialized) { return []; } final List 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 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 createState() => _VideoPlayerState(); } class _VideoPlayerState extends State { 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 createState() => _VideoScrubberState(); } class _VideoScrubberState extends State { 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 createState() => _VideoProgressIndicatorState(); } class _VideoProgressIndicatorState extends State { 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: [ LinearProgressIndicator( value: maxBuffering, valueColor: AlwaysStoppedAnimation(colors.bufferedColor), backgroundColor: colors.backgroundColor, ), LinearProgressIndicator( value: duration == 0.0 ? 0.0 : position / duration, valueColor: AlwaysStoppedAnimation(colors.playedColor), backgroundColor: Colors.transparent, ), ], ); } else { progressIndicator = LinearProgressIndicator( valueColor: AlwaysStoppedAnimation(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: [ /// 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)'; }