Skip to content

[video_player] Add DRM support for Android (Widevine) and iOS (FairPlay) - #11115

Open
nateshmbhat wants to merge 2 commits into
flutter:mainfrom
nateshmbhat:feature/drm
Open

[video_player] Add DRM support for Android (Widevine) and iOS (FairPlay)#11115
nateshmbhat wants to merge 2 commits into
flutter:mainfrom
nateshmbhat:feature/drm

Conversation

@nateshmbhat

@nateshmbhat nateshmbhat commented Feb 25, 2026

Copy link
Copy Markdown
Contributor

Adds DRM support to video_player: Widevine on Android, FairPlay on iOS and macOS.

Implements the design agreed on in flutter/flutter#182894 (design doc).

Fixes flutter/flutter#24923

API

A network source can now be given a DRM configuration:

import 'package:video_player_android/video_player_android.dart';

final VideoPlayerController controller = VideoPlayerController.networkUrl(
  Uri.parse('https://example.com/protected.mpd'),
  drmConfiguration: WidevineDrmConfiguration(
    licenseUri: Uri.parse('https://example.com/license'),
    licenseHeaders: const <String, String>{'Authorization': 'Bearer ...'},
  ),
  viewType: VideoViewType.platformView,
);

Per design-doc review feedback, the configuration types follow the shared_preferences_async model for platform-specific options rather than living in the platform interface:

Package Adds
video_player_platform_interface VideoDrmConfiguration (abstract base), DataSource.drmConfiguration
video_player_android WidevineDrmConfiguration
video_player_avfoundation FairPlayDrmConfiguration
video_player drmConfiguration on the network constructors

An app that uses DRM therefore depends on the implementation package whose configuration it constructs. Each implementation recognizes only its own subtype via a runtime type check, and rejects unsupported subtypes and non-network sources with an ArgumentError.

Implementation

Android delegates entirely to Media3: the typed configuration is mapped onto MediaItem.DrmConfiguration with C.WIDEVINE_UUID, so the plugin owns no license networking.

iOS/macOS has to participate in the key exchange, because AVFoundation has no declarative equivalent. A new FVPFairPlayResourceLoaderDelegate is installed on the AVURLAsset; it services the skd:// key requests by fetching the application certificate, generating the SPC, POSTing it to the license server with the supplied headers, and returning the CKC. Only the standard FairPlay exchange is supported (SPC as application/octet-stream, response body used as the CKC as-is).

AVAssetResourceLoader holds its delegate weakly, so the delegate is owned by the wrapped asset and player item, and FVPVideoPlayer now retains its player item — ownership is described in the design doc's lifecycle section.

Limitations

Documented in the video_player README:

  • Network sources only; no offline or persistent licenses.
  • DRM-protected video only renders in VideoViewType.platformView; in texture mode audio plays but frames are blank (protected buffers can't be copied into the Flutter texture).
  • No provider-specific FairPlay request/response customization.
  • One DRM system per source; no multi-DRM negotiation.
  • No web DRM.

Testing

  • Platform interface, video_player, Android and AVFoundation Dart unit tests for forwarding, defaults, and the rejection paths.
  • Java tests that Widevine reaches MediaItem.DrmConfiguration and that no DRM configuration is added by default.
  • Swift tests that a resource loader delegate is installed only when FairPlay is configured, and that an invalid URI throws.
  • A DRM demo in the example app, using Axinom's public test streams, for manual verification on device.

Pre-Review Checklist

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request adds comprehensive DRM support for Android (Widevine) and iOS (FairPlay) to the video_player package. It introduces new data models for DRM configuration in the platform interface, updates the VideoPlayerController to handle these configurations, and provides native implementations for both platforms. The changes are well-tested with new unit tests and a new example screen for manual testing.

Comment on lines +48 to +104
- (void)handleLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
NSError *certificateError;
NSData *certificateData = [NSData dataWithContentsOfURL:self.certificateURL
options:0
error:&certificateError];
if (certificateData == nil) {
[loadingRequest finishLoadingWithError:certificateError];
return;
}

NSString *requestContentId = self.contentId;
if (requestContentId.length == 0) {
requestContentId = loadingRequest.request.URL.absoluteString;
}
NSData *contentIdentifierData = [requestContentId dataUsingEncoding:NSUTF8StringEncoding];
if (contentIdentifierData == nil) {
[loadingRequest finishLoadingWithError:nil];
return;
}

NSError *spcError;
NSData *spcData = [loadingRequest streamingContentKeyRequestDataForApp:certificateData
contentIdentifier:contentIdentifierData
options:nil
error:&spcError];
if (spcData == nil) {
[loadingRequest finishLoadingWithError:spcError];
return;
}

NSMutableURLRequest *licenseRequest = [NSMutableURLRequest requestWithURL:self.licenseURL];
[licenseRequest setHTTPMethod:@"POST"];
[licenseRequest setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
for (NSString *headerName in self.licenseHeaders) {
[licenseRequest setValue:self.licenseHeaders[headerName] forHTTPHeaderField:headerName];
}
[licenseRequest setHTTPBody:spcData];

NSURLSessionDataTask *task = [[NSURLSession sharedSession]
dataTaskWithRequest:licenseRequest
completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
NSError *_Nullable error) {
if (error != nil) {
[loadingRequest finishLoadingWithError:error];
return;
}
if (data == nil || data.length == 0) {
[loadingRequest finishLoadingWithError:nil];
return;
}
[loadingRequest.dataRequest respondWithData:data];
[loadingRequest finishLoading];
}];
[task resume];
}

@end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using [NSData dataWithContentsOfURL:options:error:] for fetching the FairPlay certificate performs a synchronous network request. This can block the resource loader's dispatch queue, potentially impacting responsiveness.

It's recommended to use an asynchronous NSURLSessionDataTask for all network operations, similar to how the license request is handled later in this method. This ensures that all network I/O is non-blocking.

- (void)handleLoadingRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
  NSURLSessionDataTask *certTask = [[NSURLSession sharedSession]
      dataTaskWithURL:self.certificateURL
    completionHandler:^(NSData *_Nullable certificateData, NSURLResponse *_Nullable response,
                        NSError *_Nullable certificateError) {
      if (certificateError) {
        [loadingRequest finishLoadingWithError:certificateError];
        return;
      }
      if (!certificateData) {
        [loadingRequest finishLoadingWithError:nil];
        return;
      }

      NSString *requestContentId = self.contentId;
      if (requestContentId.length == 0) {
        requestContentId = loadingRequest.request.URL.absoluteString;
      }
      NSData *contentIdentifierData = [requestContentId dataUsingEncoding:NSUTF8StringEncoding];
      if (contentIdentifierData == nil) {
        [loadingRequest finishLoadingWithError:nil];
        return;
      }

      NSError *spcError;
      NSData *spcData =
          [loadingRequest streamingContentKeyRequestDataForApp:certificateData
                                             contentIdentifier:contentIdentifierData
                                                       options:nil
                                                         error:&spcError];
      if (spcData == nil) {
        [loadingRequest finishLoadingWithError:spcError];
        return;
      }

      NSMutableURLRequest *licenseRequest = [NSMutableURLRequest requestWithURL:self.licenseURL];
      [licenseRequest setHTTPMethod:@"POST"];
      [licenseRequest setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
      for (NSString *headerName in self.licenseHeaders) {
        [licenseRequest setValue:self.licenseHeaders[headerName] forHTTPHeaderField:headerName];
      }
      [licenseRequest setHTTPBody:spcData];

      NSURLSessionDataTask *task = [[NSURLSession sharedSession]
          dataTaskWithRequest:licenseRequest
            completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
                                NSError *_Nullable error) {
              if (error != nil) {
                [loadingRequest finishLoadingWithError:error];
                return;
              }
              if (data == nil || data.length == 0) {
                [loadingRequest finishLoadingWithError:nil];
                return;
              }
              [loadingRequest.dataRequest respondWithData:data];
              [loadingRequest finishLoading];
            }];
      [task resume];
    }];
  [certTask resume];
}

@stuartmorgan-g

Copy link
Copy Markdown
Collaborator

Thanks for the contribution!

This is adding non-trivial complexity and scope to the plugin (e.g., the iOS/macOS implementation now seems to be managing its own network requests which has not previously been the case), so the first step here would be a design document explaining the changes at a high level and the reasons for them, and discussing any tradeoffs and limitations, so that everyone reviewing already has context and agreement about the general approach. Once that's done and reviewed, then the PR can be reviewed.

Also, I want to set expectations very clearly up front that since this a large, complex PR, and this is not currently a critical priority for the team, code reviews will likely take significant time. Please consider whether that is going to be a significant issue for you again before starting the review process.

@nateshmbhat

nateshmbhat commented Feb 25, 2026

Copy link
Copy Markdown
Contributor Author

Per review request, I have prepared and shared a design document for this proposal:

The doc covers the high-level approach, rationale, tradeoffs, and known limitations. I’ll wait for design feedback/alignment before continuing with code-review updates on this PR.

@nateshmbhat

Copy link
Copy Markdown
Contributor Author

I have updated and moved the design doc based on the feedback left on the previous draft.

Current design doc: https://docs.google.com/document/d/1T4LRRsicr_JuSkXBAXEcv7-9BnYPhmwgYnr1d6G6_o8/edit?tab=t.0
Design-doc tracking issue: flutter/flutter#182894
Previous annotated draft: https://docs.google.com/document/d/1Nc4FoEZ-90fn37h8GE0SStkUq1wu3slQt3kneaAnSdc/edit?usp=sharing

The new draft is template-based and incorporates the earlier comments around scope, tradeoffs, limitations, and additional AVFoundation/FairPlay context. Please use the new doc for any further design feedback.

sfshaza2 pushed a commit to flutter/website that referenced this pull request Mar 9, 2026
## Description

Adds a `flutter.dev/go` redirect for the DRM support design document for
`video_player`.

The new go link points to the public design doc covering proposed
first-party DRM support for:
- Android Widevine
- iOS FairPlay

This is needed so the design doc follows the Flutter design doc process
and can be referenced from the tracking issue and review discussions.

## Related Issues

- Design doc tracking issue: flutter/flutter#182894
- Related implementation PR: flutter/packages#11115

## Testing

- Verified the redirect entry was added in the expected location.
- Verified the destination URL points to the public Google Doc.

## Presubmit checklist

- [x] If you are unwilling, or unable, to sign the CLA, even for a
_tiny_, one-word PR, please file an issue instead of a PR.
- [x] If this PR is not meant to land until a future stable release,
mark it as draft with an explanation.
- [x] This PR follows the [Google Developer Documentation Style
Guidelines](https://developers.google.com/style)—for example, it doesn't
use _i.e._ or _e.g._, and it avoids _I_ and _we_ (first-person
pronouns).
- [x] This PR uses [semantic line
breaks](https://github.com/dart-lang/site-shared/blob/main/doc/writing-for-dart-and-flutter-websites.md#semantic-line-breaks)
  of 80 characters or fewer.
@mdebbar
mdebbar removed their request for review March 13, 2026 15:05
@stuartmorgan-g

Copy link
Copy Markdown
Collaborator

From triage: @nateshmbhat Are you still planning on responding to the design doc feedback, in order to unblock this?

@nateshmbhat

nateshmbhat commented May 6, 2026

Copy link
Copy Markdown
Contributor Author

From triage: @nateshmbhat Are you still planning on responding to the design doc feedback, in order to unblock this?

yes @stuartmorgan-g i'm planning to take this forward and do whatever is needed.

@nateshmbhat

Copy link
Copy Markdown
Contributor Author

From triage: @nateshmbhat Are you still planning on responding to the design doc feedback, in order to unblock this?

Have replied to your comments in the doc

@stuartmorgan-g

Copy link
Copy Markdown
Collaborator

From triage: Other leads have now been tagged in to review the design doc; this is just waiting on that feedback.

@nateshmbhat

Copy link
Copy Markdown
Contributor Author

Design doc is signed off, so I've updated this PR to match it.

Two things changed since the last version:

1. DRM configuration types moved out of the platform interface. Per the doc thread with @tarrinneal and @stuartmorgan-g, this now follows the shared_preferences_async model: VideoDrmConfiguration stays in video_player_platform_interface as the base type carried on DataSource, while WidevineDrmConfiguration lives in video_player_android and FairPlayDrmConfiguration in video_player_avfoundation. Each implementation recognizes its own subtype with a runtime check and rejects anything else. Apps that use DRM depend on the implementation package directly; video_player re-exports only the base type.

2. Rebased onto current main. The branch predated the Swift conversion and the Pigeon plugin/instance message split in video_player_avfoundation, so rather than resolving that as a merge I rebuilt the change on top of main. The FairPlay delegate now lives in the _objc target and is constructed from VideoPlayerPlugin.swift, and FVPAVFactory's asset method takes an optional resource loader delegate instead of a set of FairPlay-specific parameters.

Also added, following the doc's documentation and testing plans: a DRM section in the video_player README covering usage and every limitation from the doc, native tests on both platforms, and a DRM demo in the example app.

The temporary dependency_overrides are the standard combination-PR ones from make-deps-path-based, so the federation safety check is expected to fail until the platform interface lands separately — happy to split this into the usual sequence of PRs whenever you'd prefer.

@nateshmbhat
nateshmbhat marked this pull request as ready for review August 15, 2026 05:45

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces DRM support for network streams in the video_player package, adding platform-specific configurations for Widevine on Android and FairPlay on iOS/macOS. The review feedback suggests optimizing the FairPlay implementation by caching the application certificate to prevent redundant network requests and capturing self weakly in asynchronous network callbacks to avoid potential retain cycles.

Comment on lines +53 to +59
@interface FVPFairPlayResourceLoaderDelegate ()
@property(nonatomic, copy, readonly) NSURL *certificateURL;
@property(nonatomic, copy, readonly) NSURL *licenseURL;
@property(nonatomic, copy, readonly) NSDictionary<NSString *, NSString *> *licenseHeaders;
@property(nonatomic, copy, readonly, nullable) NSString *contentId;
@property(nonatomic, readonly) NSURLSession *session;
@end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To optimize performance and avoid redundant network requests, we should cache the FairPlay application certificate once it is successfully fetched. The certificate is static and does not change during playback. Let's add an atomic property to store the cached certificate data.

Suggested change
@interface FVPFairPlayResourceLoaderDelegate ()
@property(nonatomic, copy, readonly) NSURL *certificateURL;
@property(nonatomic, copy, readonly) NSURL *licenseURL;
@property(nonatomic, copy, readonly) NSDictionary<NSString *, NSString *> *licenseHeaders;
@property(nonatomic, copy, readonly, nullable) NSString *contentId;
@property(nonatomic, readonly) NSURLSession *session;
@end
@interface FVPFairPlayResourceLoaderDelegate ()
@property(nonatomic, copy, readonly) NSURL *certificateURL;
@property(nonatomic, copy, readonly) NSURL *licenseURL;
@property(nonatomic, copy, readonly) NSDictionary<NSString *, NSString *> *licenseHeaders;
@property(nonatomic, copy, readonly, nullable) NSString *contentId;
@property(nonatomic, readonly) NSURLSession *session;
@property(atomic, strong, nullable) NSData *cachedCertificate;
@end

Comment on lines +102 to +115
- (void)loadContentKeyForRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
[[self.session dataTaskWithURL:self.certificateURL
completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
NSError *_Nullable error) {
NSError *validationError = FVPValidateResponse(
data, response, error, FVPFairPlayErrorCodeCertificateUnavailable,
@"Unable to fetch the FairPlay application certificate");
if (validationError != nil) {
[loadingRequest finishLoadingWithError:validationError];
return;
}
[self requestLicenseForRequest:loadingRequest certificate:data];
}] resume];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the cached certificate if it is already available, and store it upon a successful fetch to prevent redundant network requests for subsequent key requests. Additionally, capture self weakly to avoid a temporary retain cycle and prevent delayed deallocation of the delegate if the player is disposed while a certificate request is in progress.

- (void)loadContentKeyForRequest:(AVAssetResourceLoadingRequest *)loadingRequest {
  NSData *cachedCertificate = self.cachedCertificate;
  if (cachedCertificate != nil) {
    [self requestLicenseForRequest:loadingRequest certificate:cachedCertificate];
    return;
  }

  __weak typeof(self) weakSelf = self;
  [[self.session dataTaskWithURL:self.certificateURL
               completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
                                   NSError *_Nullable error) {
                 __strong typeof(weakSelf) strongSelf = weakSelf;
                 if (strongSelf == nil) {
                   return;
                 }
                 NSError *validationError = FVPValidateResponse(
                     data, response, error, FVPFairPlayErrorCodeCertificateUnavailable,
                     @"Unable to fetch the FairPlay application certificate");
                 if (validationError != nil) {
                   [loadingRequest finishLoadingWithError:validationError];
                   return;
                 }
                 strongSelf.cachedCertificate = data;
                 [strongSelf requestLicenseForRequest:loadingRequest certificate:data];
               }] resume];
}

Comment on lines +119 to +159
- (void)requestLicenseForRequest:(AVAssetResourceLoadingRequest *)loadingRequest
certificate:(NSData *)certificate {
// The content ID defaults to the skd:// URL that AVFoundation asked to resolve, which is what
// most providers key their licenses on.
NSString *contentId =
self.contentId.length > 0 ? self.contentId : loadingRequest.request.URL.absoluteString;
NSData *contentIdData = [contentId dataUsingEncoding:NSUTF8StringEncoding];

NSError *spcError;
NSData *spcData = [loadingRequest streamingContentKeyRequestDataForApp:certificate
contentIdentifier:contentIdData
options:nil
error:&spcError];
if (spcData == nil) {
[loadingRequest finishLoadingWithError:spcError];
return;
}

NSMutableURLRequest *licenseRequest = [NSMutableURLRequest requestWithURL:self.licenseURL];
licenseRequest.HTTPMethod = @"POST";
[licenseRequest setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
[self.licenseHeaders
enumerateKeysAndObjectsUsingBlock:^(NSString *name, NSString *value, BOOL *stop) {
[licenseRequest setValue:value forHTTPHeaderField:name];
}];
licenseRequest.HTTPBody = spcData;

[[self.session dataTaskWithRequest:licenseRequest
completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
NSError *_Nullable error) {
NSError *validationError = FVPValidateResponse(
data, response, error, FVPFairPlayErrorCodeLicenseUnavailable,
@"Unable to fetch the FairPlay license");
if (validationError != nil) {
[loadingRequest finishLoadingWithError:validationError];
return;
}
[loadingRequest.dataRequest respondWithData:data];
[loadingRequest finishLoading];
}] resume];
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To avoid a temporary retain cycle and prevent delayed deallocation of the delegate when the player is disposed while a license request is in progress, we should capture self weakly in the completion handler.

- (void)requestLicenseForRequest:(AVAssetResourceLoadingRequest *)loadingRequest
                     certificate:(NSData *)certificate {
  // The content ID defaults to the skd:// URL that AVFoundation asked to resolve, which is what
  // most providers key their licenses on.
  NSString *contentId =
      self.contentId.length > 0 ? self.contentId : loadingRequest.request.URL.absoluteString;
  NSData *contentIdData = [contentId dataUsingEncoding:NSUTF8StringEncoding];

  NSError *spcError;
  NSData *spcData = [loadingRequest streamingContentKeyRequestDataForApp:certificate
                                                       contentIdentifier:contentIdData
                                                                 options:nil
                                                                   error:&spcError];
  if (spcData == nil) {
    [loadingRequest finishLoadingWithError:spcError];
    return;
  }

  NSMutableURLRequest *licenseRequest = [NSMutableURLRequest requestWithURL:self.licenseURL];
  licenseRequest.HTTPMethod = @"POST";
  [licenseRequest setValue:@"application/octet-stream" forHTTPHeaderField:@"Content-Type"];
  [self.licenseHeaders
      enumerateKeysAndObjectsUsingBlock:^(NSString *name, NSString *value, BOOL *stop) {
        [licenseRequest setValue:value forHTTPHeaderField:name];
      }];
  licenseRequest.HTTPBody = spcData;

  __weak typeof(self) weakSelf = self;
  [[self.session dataTaskWithRequest:licenseRequest
                   completionHandler:^(NSData *_Nullable data, NSURLResponse *_Nullable response,
                                       NSError *_Nullable error) {
                     __strong typeof(weakSelf) strongSelf = weakSelf;
                     if (strongSelf == nil) {
                       return;
                     }
                     NSError *validationError = FVPValidateResponse(
                         data, response, error, FVPFairPlayErrorCodeLicenseUnavailable,
                         @"Unable to fetch the FairPlay license");
                     if (validationError != nil) {
                       [loadingRequest finishLoadingWithError:validationError];
                       return;
                     }
                     [loadingRequest.dataRequest respondWithData:data];
                     [loadingRequest finishLoading];
                   }] resume];
}

Implements the design agreed in
flutter/flutter#182894.

Adds `VideoDrmConfiguration` to the platform interface as a common base
type, and `DataSource.drmConfiguration` to carry it. Following the
shared_preferences model for platform-specific options, the concrete
configurations live in the implementation packages:

* `WidevineDrmConfiguration` in video_player_android, mapped onto
  Media3's `MediaItem.DrmConfiguration`.
* `FairPlayDrmConfiguration` in video_player_avfoundation, serviced by a
  new `FVPFairPlayResourceLoaderDelegate` that fetches the application
  certificate, generates the SPC, and exchanges it for a CKC.

Each implementation recognizes only its own subtype, and rejects both
unsupported subtypes and non-network sources with an `ArgumentError`.

`VideoPlayerController.networkUrl` gains an optional `drmConfiguration`;
everything else is unchanged, and DRM is entirely opt-in.
Addresses lifetime issues found in review of the FairPlay resource loader
delegate.

The delegate started untracked NSURLSession tasks and never implemented
resourceLoader:didCancelLoadingRequest:, so a key request cancelled by
AVFoundation - on seek, item replacement, or teardown - kept running and
its completion handler still finished the cancelled request. Requests are
now tracked alongside the task servicing them, cancellation stops that
task, and every completion path checks that the request is still live
before finishing it.

The completion blocks also captured the delegate strongly, and the
delegate owns the session that retains those blocks. A stalled request
therefore kept the delegate, and through it the player item, alive until
the network timed out. The blocks now capture it weakly, and dealloc
invalidates the session so outstanding tasks are cancelled.

Finally, FVPVideoPlayer released the AVPlayer's item on dispose but held
onto the wrapper, which owns the resource loader delegate, until the
player itself was deallocated. Dispose now releases it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[video_player] Support DRM content

2 participants