From 8983d879533209a9343682e514fd5610356f6f3d Mon Sep 17 00:00:00 2001 From: Sumasree8 Date: Fri, 28 Aug 2026 15:11:55 +0530 Subject: [PATCH] feat: Chromecast, preloading and precaching --- .gitignore | 19 + CHANGELOG.md | 18 + README.md | 592 ++++++- android/build.gradle | 69 + android/settings.gradle | 1 + android/src/main/AndroidManifest.xml | 1 + .../videoplayer/FastPixMediaCacheWarmer.kt | 159 ++ .../videoplayer/FastPixVideoPlayerPlugin.kt | 99 ++ example/README.md | 268 +-- .../android/app/src/main/AndroidManifest.xml | 26 + .../drm_preload_device_test.dart | 155 ++ .../engine_dispose_baseline_test.dart | 95 + .../integration_test/ios_cache_path_test.dart | 105 ++ .../ios_precache_e2e_test.dart | 66 + .../ios_preload_device_test.dart | 140 ++ .../ios_segment_cache_test.dart | 103 ++ .../preload_cycle_diagnostic_test.dart | 106 ++ .../integration_test/preload_device_test.dart | 278 +++ .../integration_test/seed_override_test.dart | 56 + example/integration_test/seed_smoke_test.dart | 35 + example/ios/Podfile.lock | 56 + example/ios/Runner.xcodeproj/project.pbxproj | 58 +- example/ios/Runner/Info.plist | 9 + example/lib/main.dart | 235 +-- example/lib/src/cast_service.dart | 204 +++ example/lib/src/catalog.dart | 158 ++ example/lib/src/demo_seed.dart | 135 ++ example/lib/src/home_screen.dart | 454 +++++ example/lib/src/models/demo_stream.dart | 171 ++ example/lib/src/models/playback_queue.dart | 108 ++ example/lib/src/playback_config.dart | 25 + example/lib/src/precache_probe.dart | 72 + example/lib/src/preload_probe.dart | 107 ++ example/lib/src/theme.dart | 96 + example/lib/src/watch_screen.dart | 1064 +++++++++++ example/lib/src/widgets/cast_scrubber.dart | 185 ++ example/lib/src/widgets/cast_sheets.dart | 406 +++++ example/lib/src/widgets/poster_card.dart | 156 ++ example/lib/src/widgets/precache_panel.dart | 108 ++ example/lib/src/widgets/preload_badge.dart | 242 +++ .../lib/src/widgets/stream_form_sheet.dart | 302 ++++ example/pubspec.lock | 93 +- example/pubspec.yaml | 30 + example/test/cast_scrubber_test.dart | 180 ++ example/test/demo_seed_test.dart | 80 + example/test/playback_queue_test.dart | 106 ++ example/test/widget_test.dart | 100 +- ios/Classes/FastPixCachingAssetHook.h | 23 + ios/Classes/FastPixCachingAssetHook.m | 103 ++ ios/Classes/FastPixFairPlayPatch.h | 44 + ios/Classes/FastPixFairPlayPatch.m | 401 +++++ ios/Classes/FastPixPlaybackAdoption.h | 27 + ios/Classes/FastPixPlaybackAdoption.m | 241 +++ ios/Classes/FastPixPlayerItemPreloader.swift | 359 ++++ ios/Classes/FastPixSegmentPrecacher.swift | 381 ++++ ios/Classes/FastPixVideoPlayerPlugin.swift | 137 ++ ios/fastpix_video_player.podspec | 35 + lib/fastpix_video_player.dart | 28 + .../enums/fastpix_cast_segment_format.dart | 23 + lib/src/enums/fastpix_cast_state.dart | 59 + lib/src/enums/fastpix_network_type.dart | 48 + lib/src/enums/fastpix_precache_status.dart | 28 + lib/src/enums/fastpix_preload_status.dart | 24 + lib/src/enums/fastpix_preload_strategy.dart | 32 + lib/src/fastpix_cast_button.dart | 90 + lib/src/fastpix_cast_controller.dart | 1566 +++++++++++++++++ lib/src/fastpix_player_controller.dart | 295 +++- lib/src/fastpix_player_widget.dart | 248 ++- lib/src/fastpix_precache_manager.dart | 455 +++++ lib/src/fastpix_preload_manager.dart | 665 +++++++ lib/src/models/fastpix_cast_device.dart | 61 + lib/src/models/fastpix_cast_error.dart | 199 +++ lib/src/models/fastpix_cast_event.dart | 100 ++ lib/src/models/fastpix_cast_text_track.dart | 62 + .../models/fastpix_player_data_source.dart | 191 +- .../fastpix_player_drm_configuration.dart | 38 +- .../models/fastpix_player_event_types.dart | 102 ++ lib/src/models/fastpix_precache_event.dart | 71 + lib/src/models/fastpix_preload_event.dart | 117 ++ .../fastpix_better_player_configuration.dart | 286 +++ lib/src/utils/fastpix_fairplay_bridge.dart | 95 + lib/src/utils/fastpix_host_warmer.dart | 134 ++ lib/src/utils/fastpix_manifest_warmer.dart | 177 ++ lib/src/utils/fastpix_network_monitor.dart | 94 + lib/src/utils/fastpix_playstart_trace.dart | 83 + lib/src/utils/fastpix_warm_log.dart | 51 + pubspec.yaml | 51 +- test/drm_cast_urls_test.dart | 31 + ...tpix_better_player_configuration_test.dart | 352 ++++ .../fastpix_buffering_configuration_test.dart | 161 ++ test/fastpix_cast_button_test.dart | 104 ++ test/fastpix_cast_error_test.dart | 112 ++ test/fastpix_cast_seek_volume_test.dart | 85 + test/fastpix_host_warmer_test.dart | 127 ++ test/fastpix_ios_cache_gate_test.dart | 59 + test/fastpix_manifest_warmer_test.dart | 240 +++ test/fastpix_network_type_test.dart | 105 ++ test/fastpix_precache_manager_test.dart | 301 ++++ test/fastpix_preload_manager_test.dart | 311 ++++ test/fastpix_preload_no_regression_test.dart | 103 ++ test/fastpix_subtitles_test.dart | 272 +++ 101 files changed, 16459 insertions(+), 459 deletions(-) create mode 100644 android/build.gradle create mode 100644 android/settings.gradle create mode 100644 android/src/main/AndroidManifest.xml create mode 100644 android/src/main/kotlin/com/fastpix/videoplayer/FastPixMediaCacheWarmer.kt create mode 100644 android/src/main/kotlin/com/fastpix/videoplayer/FastPixVideoPlayerPlugin.kt create mode 100644 example/integration_test/drm_preload_device_test.dart create mode 100644 example/integration_test/engine_dispose_baseline_test.dart create mode 100644 example/integration_test/ios_cache_path_test.dart create mode 100644 example/integration_test/ios_precache_e2e_test.dart create mode 100644 example/integration_test/ios_preload_device_test.dart create mode 100644 example/integration_test/ios_segment_cache_test.dart create mode 100644 example/integration_test/preload_cycle_diagnostic_test.dart create mode 100644 example/integration_test/preload_device_test.dart create mode 100644 example/integration_test/seed_override_test.dart create mode 100644 example/integration_test/seed_smoke_test.dart create mode 100644 example/lib/src/cast_service.dart create mode 100644 example/lib/src/catalog.dart create mode 100644 example/lib/src/demo_seed.dart create mode 100644 example/lib/src/home_screen.dart create mode 100644 example/lib/src/models/demo_stream.dart create mode 100644 example/lib/src/models/playback_queue.dart create mode 100644 example/lib/src/playback_config.dart create mode 100644 example/lib/src/precache_probe.dart create mode 100644 example/lib/src/preload_probe.dart create mode 100644 example/lib/src/theme.dart create mode 100644 example/lib/src/watch_screen.dart create mode 100644 example/lib/src/widgets/cast_scrubber.dart create mode 100644 example/lib/src/widgets/cast_sheets.dart create mode 100644 example/lib/src/widgets/poster_card.dart create mode 100644 example/lib/src/widgets/precache_panel.dart create mode 100644 example/lib/src/widgets/preload_badge.dart create mode 100644 example/lib/src/widgets/stream_form_sheet.dart create mode 100644 example/test/cast_scrubber_test.dart create mode 100644 example/test/demo_seed_test.dart create mode 100644 example/test/playback_queue_test.dart create mode 100644 ios/Classes/FastPixCachingAssetHook.h create mode 100644 ios/Classes/FastPixCachingAssetHook.m create mode 100644 ios/Classes/FastPixFairPlayPatch.h create mode 100644 ios/Classes/FastPixFairPlayPatch.m create mode 100644 ios/Classes/FastPixPlaybackAdoption.h create mode 100644 ios/Classes/FastPixPlaybackAdoption.m create mode 100644 ios/Classes/FastPixPlayerItemPreloader.swift create mode 100644 ios/Classes/FastPixSegmentPrecacher.swift create mode 100644 ios/Classes/FastPixVideoPlayerPlugin.swift create mode 100644 ios/fastpix_video_player.podspec create mode 100644 lib/src/enums/fastpix_cast_segment_format.dart create mode 100644 lib/src/enums/fastpix_cast_state.dart create mode 100644 lib/src/enums/fastpix_network_type.dart create mode 100644 lib/src/enums/fastpix_precache_status.dart create mode 100644 lib/src/enums/fastpix_preload_status.dart create mode 100644 lib/src/enums/fastpix_preload_strategy.dart create mode 100644 lib/src/fastpix_cast_button.dart create mode 100644 lib/src/fastpix_cast_controller.dart create mode 100644 lib/src/fastpix_precache_manager.dart create mode 100644 lib/src/fastpix_preload_manager.dart create mode 100644 lib/src/models/fastpix_cast_device.dart create mode 100644 lib/src/models/fastpix_cast_error.dart create mode 100644 lib/src/models/fastpix_cast_event.dart create mode 100644 lib/src/models/fastpix_cast_text_track.dart create mode 100644 lib/src/models/fastpix_precache_event.dart create mode 100644 lib/src/models/fastpix_preload_event.dart create mode 100644 lib/src/utils/fastpix_better_player_configuration.dart create mode 100644 lib/src/utils/fastpix_fairplay_bridge.dart create mode 100644 lib/src/utils/fastpix_host_warmer.dart create mode 100644 lib/src/utils/fastpix_manifest_warmer.dart create mode 100644 lib/src/utils/fastpix_network_monitor.dart create mode 100644 lib/src/utils/fastpix_playstart_trace.dart create mode 100644 lib/src/utils/fastpix_warm_log.dart create mode 100644 test/drm_cast_urls_test.dart create mode 100644 test/fastpix_better_player_configuration_test.dart create mode 100644 test/fastpix_buffering_configuration_test.dart create mode 100644 test/fastpix_cast_button_test.dart create mode 100644 test/fastpix_cast_error_test.dart create mode 100644 test/fastpix_cast_seek_volume_test.dart create mode 100644 test/fastpix_host_warmer_test.dart create mode 100644 test/fastpix_ios_cache_gate_test.dart create mode 100644 test/fastpix_manifest_warmer_test.dart create mode 100644 test/fastpix_network_type_test.dart create mode 100644 test/fastpix_precache_manager_test.dart create mode 100644 test/fastpix_preload_manager_test.dart create mode 100644 test/fastpix_preload_no_regression_test.dart create mode 100644 test/fastpix_subtitles_test.dart diff --git a/.gitignore b/.gitignore index eb6c05c..d7c57a8 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,22 @@ migrate_working_dir/ .flutter-plugins .flutter-plugins-dependencies build/ + +# Local fork of flutter_chrome_cast (one Kotlin file patched for fMP4 HLS). +# Build-time override only, not project source. +third_party/ + +# Local stream configuration for the example app. +# +# The demo's default playback IDs are committed in lib/src/demo_seed.dart, so +# nothing here is required to build or run. This file is for local overrides +# only, and is ignored because it is the natural place someone will paste a +# playback or DRM token — which must never reach the repo or a built APK. +example/.env + + +# Gradle build output from the plugin's own android/ module. Regenerated on +# every build, machine-specific, and includes lock files that conflict. +android/.gradle/ +android/build/ +android/local.properties diff --git a/CHANGELOG.md b/CHANGELOG.md index 036e7b9..3b0a7bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.0.2] + +### Added +- **Preloading**: `FastPixPreloadManager` warms upcoming sources so the next tap skips the manifest fetch, license acquisition and decoder setup. Two strategies — `network` warms the connection and manifest, `player` builds a detached player that playback then adopts. Warm depth, per-platform player caps, Cast awareness and a full event stream included +- **Precaching**: `FastPixPrecacheManager` writes manifest and segment bytes to disk ahead of playback, with byte accounting, request coalescing and its own event stream. Reads back into playback on Android; on iOS it stores but does not yet shorten a later start +- **Chromecast**: `FastPixCastController` for discovery, session management, remote transport control, receiver volume and subtitle selection, with `startCastingFrom` / `stopCastingTo` moving playback between phone and TV at the position it left off. Cast failures are normalized into `FastPixCastErrorCode`, including the Android 13+ nearby devices permission that otherwise makes discovery find nothing silently +- **Screen capture protection**: `secureScreen` on `FastPixPlayerDrmConfiguration` applies Android's `FLAG_SECURE` while a DRM source plays. On by default, best effort, and window wide + +### Changed +- **iOS FairPlay setup is now self-contained.** The resource-loader patch ships inside the plugin and installs itself at registration, so the manual edit to the cached engine that iOS DRM used to require is no longer needed. Nothing to do on upgrade — remove the manual patch step from your build if you scripted it +- **Engine floor raised to `better_player_plus: ^1.2.1`**, from `^1.0.8`. FairPlay relies on the engine's Objective-C surface, which settled in 1.2.1. The old floor allowed older engines to satisfy the constraint, so a project resolving to one could see iOS DRM behave inconsistently. Raising the floor makes the supported engine explicit rather than resolution-dependent. Most projects already resolve to 1.2.1 or later and will see no change +- Pinned the Material control theme in the iOS player so controls render consistently across platforms +- Reworked the example app UI, including working subtitles and cast track selection + +### Documentation +- Corrected the stated cause of disabled iOS HLS caching. It is the local cache proxy failing on signed FastPix URLs with `CoreMediaErrorDomain -12642`, not FairPlay holding the asset's single resource-loader delegate. The distinction matters because caching is off for unprotected iOS HLS too +- Corrected the `FastPixPlayerDrmConfiguration.validate` signature, the reachable set of pre-flight DRM error codes, two Chromecast behaviours around DRM refusal and local resume, and the Android manifest snippet, which was missing `FOREGROUND_SERVICE_MEDIA_PLAYBACK` + ## [1.0.1] ### Added diff --git a/README.md b/README.md index 87225e3..f2b3b40 100644 --- a/README.md +++ b/README.md @@ -10,13 +10,14 @@ This SDK simplifies HLS video playback by offering a wide range of customization - `loop`: Allows the video to repeat automatically after it finishes, perfect for continuous viewing scenarios. - ## Security: - - the `token` attribute is required to play private or DRM protected streams + - The `token` attribute is required to play private or DRM protected streams. - **Note:** You can skip the token for public streams. - ## DRM playback: - Protected media plays through the FastPix license server using `drmConfiguration`, with Widevine on Android and FairPlay on iOS. - License and certificate URLs are derived from the playback ID, so only the DRM token has to be supplied. - DRM failures are normalized into stable error codes with actionable messages, so callers can refresh a token, retry, or fall back without parsing platform error strings. + - Screenshots and screen recording are blocked during DRM playback on Android by default — see [Screen capture protection](#screen-capture-protection). - ## Inbuilt error handling: - The player includes inbuilt error handling that displays appropriate error messages, helping developers quickly understand and address any issues that arise during playback. @@ -25,6 +26,16 @@ This SDK simplifies HLS video playback by offering a wide range of customization - The player automatically detects subtitles from the manifest file and displays them during playback. This ensures that users can easily access available subtitle tracks without additional configuration. - Users can switch between available subtitles during playback, offering a personalized viewing experience. This feature allows viewers to choose their preferred language option easily. +- ## Chromecast: + - `FastPixCastController` discovers receivers, manages the session, and hands playback back and forth between the phone and the TV with `startCastingFrom` / `stopCastingTo`, so playback resumes at the position it left off. + - Remote transport control (play, pause, seek, stop), receiver volume, and subtitle selection, with cast state, device list and subtitle tracks exposed as streams for driving cast UI. + - Cast failures are normalized into stable error codes the same way DRM failures are, including the Android 13+ nearby devices permission that otherwise makes discovery silently find nothing. + +- ## Preloading and precaching: + - `FastPixPreloadManager` warms upcoming sources so the next tap skips the manifest fetch, the DRM license acquisition and decoder setup — either the network path alone, or a whole player that playback then adopts. + - `FastPixPrecacheManager` writes bytes to disk ahead of playback, so a later session starts a round trip closer to the first frame. + - Both are best effort and never a precondition: any failure falls through to ordinary playback, and neither reports on the playback error channel — see [Preloading and Precaching](#preloading-and-precaching). + - ## Advanced stream control: - The player supports `onDemand` and `live` stream capabilities by utilizing specified `streamType`, enabling a versatile playback experience based on content type. - Manage video quality with `minResolution`, `maxResolution`, `resolution` and `renditionOrder` options, allowing either automated or controlled playback quality adjustments. @@ -41,19 +52,19 @@ To get started with the FastPix Player SDK we need some prerequisites, follow th # Installation: -To get started with the SDK, first install the FastPix Player SDK , you can use `flutter pub add fastpix_player` command to directly add it: +To get started with the SDK, first install the FastPix Player SDK. You can use the `flutter pub add fastpix_video_player` command to add it directly: Or Add the dependency in your `pubspec.yaml`: ```yaml dependencies: - fastpix_video_player: 1.0.1 + fastpix_video_player: 1.0.2 ``` ### Basic Usage Example ```dart import 'package:flutter/material.dart'; -import 'package:fastpix_player/fastpix_video_player.dart'; +import 'package:fastpix_video_player/fastpix_video_player.dart'; void main() { runApp(const MyApp()); @@ -94,11 +105,20 @@ class _FastPixPlayerDemoState extends State { final dataSource = FastPixPlayerDataSource.hls( playbackId: 'your-playback-id-here', title: 'Sample HLS Stream', - description: 'A sample HLS stream from staging.metrix.com', + description: 'A sample HLS stream from stream.fastpix.com', thumbnailUrl: 'https://www.example.com/thumbnail.jpg', ); - final configuration = FastPixPlayerConfiguration(); + // workspaceId, viewerId and beaconUrl are positional and required: they + // identify the stream to FastPix analytics. + final configuration = FastPixPlayerConfiguration( + 'your-workspace-id', + 'your-viewer-id', + 'your-beacon-url', + controlsConfiguration: const FastPixPlayerControlsConfiguration( + autoPlay: true, + ), + ); // Initialize the controller controller = FastPixPlayerController(); @@ -111,7 +131,6 @@ class _FastPixPlayerDemoState extends State { controller: controller, width: 350, height: 200, - aspectRatio: FastPixAspectRatio.ratio16x9, ); } @@ -127,24 +146,23 @@ class _FastPixPlayerDemoState extends State { FastPix Player provides advanced quality control options: -```dart -// Quality control configuration -final qualityControl = FastPixPlayerQualityControl( - // Target specific resolution - resolution: FastPixResolution.p720, - - // Or set min/max resolution range - minResolution: FastPixResolution.p480, - maxResolution: FastPixResolution.p1080, - - // Rendition order (quality selection priority) - renditionOrder: FastPixRenditionOrder.desc, // High to low quality -); +The quality parameters live on the data source itself and travel to FastPix as +URL parameters. A dimension left unset — or set to `auto` — is not sent at all, +leaving the choice to the player. -// Apply quality control to data source +```dart final dataSource = FastPixPlayerDataSource.hls( playbackId: 'your-playback-id', - qualityControl: qualityControl, + + // Target a specific resolution + resolution: FastPixPlayerVideoQuality.p720, + + // Or set a min/max resolution range + minResolution: FastPixPlayerVideoQuality.p480, + maxResolution: FastPixPlayerVideoQuality.p1080, + + // Rendition order (quality selection priority) + renditionOrder: FastpixPlayerRenditionOrder.desc, // High to low quality ); ``` @@ -154,13 +172,29 @@ FastPix Player provides multiple widget options: #### Basic Player Widget ```dart -FastPixPlayer( controller: controller, +FastPixPlayer( + controller: controller, width: 350, height: 200, - aspectRatio: FastPixAspectRatio.ratio16x9, showLoadingIndicator: true, loadingIndicatorColor: Colors.white, - showErrorDetails: false, +) +``` + +The widget sizes itself from the video's own aspect ratio; there is no aspect +ratio parameter to set. + +#### Player Widget With A Cast Button + +Pass a `FastPixCastController` to put the cast glyph in the player's own control +bar. `onCastPressed` is left to the app so the device picker matches the rest of +it — without it the button is inert. + +```dart +FastPixPlayer( + controller: controller, + castController: cast, + onCastPressed: onCastPressed, ) ``` @@ -185,15 +219,14 @@ final currentState = controller.currentState; final currentPosition = controller.getCurrentPosition(); final totalDuration = controller.getTotalDuration(); -// Data source management -await controller.updateDataSource(newDataSource); -await controller.updateConfiguration(newConfiguration); -await controller.updateDataSourceAndConfiguration( - dataSource: newDataSource, - configuration: newConfiguration, -); +// Lifecycle +controller.reset(); +await controller.dispose(); ``` +To play a different stream, call `initialize` again with the new data source — +it clears the retained errors and state from the previous attempt. + ### Public Media ```dart @@ -257,13 +290,28 @@ FastPixPlayerDrmConfiguration( ); ``` -Local caching is disabled automatically for DRM sources, since encrypted segments must never be cached. +Caching is unaffected by DRM on Android. media3 keeps `DrmSessionManager` and `CacheDataSource` orthogonal, so cached segments stay encrypted on disk and the license is fetched fresh at playback to decrypt them — ordinary behaviour for a streaming player. Only *offline* playback needs a persistent license, which is a separate feature. -> **iOS note:** `better_player_plus` routes FairPlay through an EZDRM specific resource loader that rewrites the license URL, so FastPix FairPlay playback does not currently work on iOS without patching the plugin. Widevine playback on Android is fully supported. +On iOS, caching is disabled for HLS playback, DRM or not, so `cacheEnabled: true` is ignored there. The cause is not DRM: the engine's cache serves bytes through a local proxy, and that proxy does not survive a signed FastPix URL. An unprotected stream fails outright with `CoreMediaErrorDomain error -12642`, a hard failure rather than a slow start. Other iOS formats still honour `cacheEnabled`. + +The single delegate slot is a separate constraint. An `AVURLAsset` has exactly one `AVAssetResourceLoader` delegate, which FairPlay already owns on protected content, and that is why *precaching* is refused for DRM on iOS. + +#### Screen capture protection + +`secureScreen` applies Android's `FLAG_SECURE` while a DRM source plays, and is **on by default**. The flag belongs to the Activity, so the whole host app is unscreenshottable until the player is disposed — set it to `false` if screenshots elsewhere in your app must keep working. + +```dart +FastPixPlayerDrmConfiguration( + drmToken: 'drm-jwt-token', + secureScreen: false, +); +``` + +No effect on iOS, where FairPlay already blanks protected video in recordings. #### DRM Error Handling -An unusable DRM setup is rejected before playback starts: `initialize` throws a `FastPixDrmException` and also emits a `FastPixPlayerDrmErrorEvent`, so a bad configuration surfaces immediately instead of as an endless spinner. Failures that happen during playback are classified from the platform error into the same set of codes. +An unusable DRM setup is rejected before playback starts: `initialize` throws a `FastPixDrmException` and also emits a `FastPixPlayerDrmErrorEvent`, so a bad configuration surfaces immediately instead of as an endless spinner. Only three codes are reachable this way — a missing DRM token, a missing playback token, and an unsupported platform — and the check runs only when the data source carries a DRM configuration at all. Every other code below is classified from a platform error, so it arrives after playback has already been attempted. ```dart try { @@ -328,6 +376,376 @@ debugPrint(diagnosis?.summary); // Human readable cause debugPrint(diagnosis?.probes.join(' · ')); // Per-endpoint results ``` +## Preloading and Precaching + +Two independent optimisations for the tap-to-first-frame path. Both are **best effort and never a precondition**: every failure — a timeout, a refused adoption, an exhausted decoder budget, a missing platform channel — falls through to exactly the playback you get today. Neither can make playback fail or wait, and both report on their own event channel so a warm-up that did not finish never surfaces as a playback error. + +| | Preloading | Precaching | +| --- | --- | --- | +| Lives in | Memory, this app session | Disk, survives a restart | +| Warms | Connection, manifest, optionally a whole player | Manifest and segment bytes | +| Entry point | `FastPixPreloadManager.instance` | `FastPixPrecacheManager.instance` | + +They share no state. Use them together rather than choosing between them. + +### Preloading + +Declare what is coming next. `preload` takes the state of the world rather than a command — it diffs against what it already holds, cancels departures, keeps survivors, and starts only arrivals — so it is safe to call on every scroll frame. + +```dart +await FastPixPreloadManager.instance.preload( + upcomingSources, // the next few items, in order + configuration: playerConfiguration, + strategy: FastPixPreloadStrategy.player, + window: 3, + warmDrm: true, +); +``` + +**Strategies.** `network` (the default) fetches the manifest so DNS and the CDN edge are hot; it allocates no platform player, and its `window` is unbounded. `player` builds a real, detached player and acquires the DRM licence, so playback can adopt it and start immediately. + +**How deep a network warm goes** is set once on the manager, not per call. `FastPixPreloadManager.instance.warmDepth` takes `FastPixWarmDepth.master` (the default, one request), `variant` (the master plus the chosen rendition playlist) or `segments` (the variant plus its opening segments, two by default). Deeper is warmer and costs more bandwidth against the video already playing. + +**Every warm is capped at twelve seconds**, `FastPixPreloadManager.warmTimeout`. A warm that overruns is failed and logged, and playback cold-starts. Network warms run one at a time so they do not compete with each other for the same bandwidth. + +**The window is capped for `player`.** One on Android, three on iOS — `FastPixPreloadManager.maxPlayerWindow`. An Android warm is a whole ExoPlayer plus, for DRM, a `MediaDrm` session that the device caps separately. Requests past the cap are dropped rather than queued, and the clamp is logged, because exceeding it does not fail the preload — it fails live playback minutes later. + +**Adoption requires a matching configuration.** Pass `initialize` the same `FastPixPlayerConfiguration` you passed `preload`. `BetterPlayerConfiguration` is final on the controller, so a player warmed for different controls or fit can never be corrected; a mismatch is refused and logged with both fingerprints, and playback cold-starts. Set `adoptPreloaded: false` on `initialize` to force a cold start when measuring baseline latency. + +A `player` warm is skipped, with a logged reason, while a Cast session is active (a local decoder would be spent on playback happening on the receiver), for live streams (a parked live player drifts behind the live edge), and for DRM when `warmDrm: false`. A `network` warm is subject to none of these — it holds no decoder and acquires no licence. A source already in the window is left alone rather than re-warmed, under either strategy. + +```dart +FastPixPreloadManager.instance.statusOf(playbackId); // queued | loading | ready | failed | cancelled +FastPixPreloadManager.instance.isReady(playbackId); +FastPixPreloadManager.instance.cancel(playbackId); // on eviction, never on mount +FastPixPreloadManager.instance.clearAll(); +FastPixPreloadManager.instance.dispose(); // releases every warm player; the manager stays usable +``` + +Do not call `cancel` when the player mounts. Adoption happens after mount, so cancelling there throws away exactly the work about to be used. + +Wire up Cast awareness once, if you cast: + +```dart +FastPixPreloadManager.instance.isCastActive = () => castController.isConnected; +``` + +Lifecycle events arrive as `FastPixPreloadStartedEvent`, `…ReadyEvent`, `…FailedEvent`, `…CancelledEvent` and `…ConsumedEvent` on `FastPixPreloadManager.instance.eventManager`. All five carry the playback ID, the strategy and the network type in effect, so a warm can be attributed to the connection that paid for it; `…Ready` adds `elapsed` and `…Failed` adds `reason`. + +### Precaching + +Writes bytes to disk ahead of playback, in the cache the player reads from. + +```dart +final status = await FastPixPrecacheManager.instance.precacheManifest(source); +final bytes = FastPixPrecacheManager.instance.bytesWrittenFor(source.playbackId); + +await FastPixPrecacheManager.instance.precacheAll(upcomingSources); +await FastPixPrecacheManager.instance.stop(source); // abandon a warm in flight +FastPixPrecacheManager.instance.statusOf(playbackId); // idle | cached | failed | unsupported +FastPixPrecacheManager.instance.clearStatuses(); +``` + +`precacheManifest` never throws; it returns `idle`, `cached`, `failed` or `unsupported`. A platform that reports success but writes **zero bytes is treated as a failure** — the byte count is the honest signal, and `bytesWrittenFor` exposes it. A repeat request for something already cached or already in flight is coalesced and returns `cached` rather than fetching twice. `precacheAll` runs a list sequentially on purpose, since these requests share bandwidth with the video currently playing. A manifest is read up to `FastPixPrecacheManager.manifestByteCeiling`, 512 KB. + +Refused, with the reason reported, on platforms other than Android and iOS, for live sources (a live playlist is rewritten continuously, so a cached copy is stale on arrival), for `cacheEnabled: false`, and for DRM on iOS — caching there needs the asset's resource-loader delegate, which FairPlay already owns on protected content. + +**Android.** The master playlist is written into the same media3 cache playback reads from, so a warm feeds the next start. media3 keys HLS entries by request URI and offers no override for it, so this pays off while the URL is stable; if the playback token is re-resolved between the warm and playback, the entry is written under one key and read under another and the warm is silently unused. Preloading is unaffected by that, because it keys by playback ID in Dart. + +**iOS.** Playlists and the opening segments are fetched and stored on disk keyed by playback ID, which survives a token refresh. Playback does not yet read from that store, so on iOS precaching currently costs bandwidth and disk without shortening a later start — prefer preloading there today. + +Events arrive as `FastPixPrecacheStartedEvent`, `…CachedEvent` and `…FailedEvent` on `FastPixPrecacheManager.instance.eventManager`. `…Cached` carries `bytesWritten`; `…Failed` carries the refusal `status` alongside its `reason`, which is how a genuine failure is told apart from an unsupported source. + +### Watching what happened + +Both features fail silently by design, so every decision is logged — skips and refusals as loudly as successes. Logging is on in debug builds and silent in release; force it with `FastPixWarmLog.enabled = true`. + +```bash +flutter run | grep -E "preloading|precaching" +adb logcat | grep -E "preloading|precaching" +``` + +The line worth grepping for is `ADOPTED`, and its absence is the difference between preloading working and preloading merely running. + + +## Chromecast + +Casting is not screen mirroring. The receiver fetches the stream itself, directly from FastPix, and `FastPixCastController` only sends it commands. Three consequences shape the whole API: + +- The stream URL has to be reachable by the receiver, so authentication must travel in the URL. `FastPixPlayerDataSource.url` already carries the playback token as a query parameter, but `headers` are **dropped** — the receiver makes its own request and never sees them. +- Local and remote playback are mutually exclusive. Use `startCastingFrom` and `stopCastingTo` to move between them rather than driving both players by hand. +- DRM streams cannot be cast through Google's Default Media Receiver. See [DRM on Chromecast](#drm-on-chromecast). + +Casting is supported on Android and iOS. On any other platform the controller settles on `FastPixCastState.unavailable` instead of throwing, so cast UI can be built unconditionally and let the state hide it. + +### Platform setup + +#### Android + +Add the discovery permissions and the Cast framework configuration to `android/app/src/main/AndroidManifest.xml`: + +```xml + + + + + + + + + + + + + + + +``` + +The runtime `NEARBY_WIFI_DEVICES` request is made by the SDK itself from `startDiscovery()`; the manifest entry is all your app has to add. Casting also needs Google Play Services — when it is missing or too old the failure arrives as `FP_CAST_PLAY_SERVICES_UNAVAILABLE`. + +#### iOS + +Add the local network keys to `ios/Runner/Info.plist`. `NSBonjourServices` must list the Cast service and, when you use a custom receiver, the service for its application ID: + +```xml +NSLocalNetworkUsageDescription +${PRODUCT_NAME} uses the local network to discover Cast-enabled devices on your WiFi network. +NSBluetoothAlwaysUsageDescription +${PRODUCT_NAME} uses Bluetooth to discover nearby Cast-enabled devices. +NSBonjourServices + + _googlecast._tcp + _YOUR_APP_ID._googlecast._tcp + +``` + +iOS gives no callback when the local network permission is denied: discovery simply returns zero devices, which is indistinguishable from a network with no receivers on it. + +### Quick start + +```dart +final cast = FastPixCastController( + // Defaults to Google's Default Media Receiver. A custom receiver ID is + // needed for branding, DRM, or receiver-side analytics. + appId: 'YOUR_RECEIVER_APP_ID', + // Set to fmp4 for CMAF packaged streams — see "Segment format" below. + segmentFormat: FastPixCastSegmentFormat.fmp4, +); + +// Cast state drives the cast button: show it only once a receiver exists. +cast.stateStream.listen((state) => setState(() => _canCast = state.canCast)); +cast.devicesStream.listen((devices) => setState(() => _devices = devices)); + +await cast.initialize(); + +// Discovery is expensive in battery and Wi-Fi traffic. Start it when cast UI +// opens and stop it when it closes. +await cast.startDiscovery(); +``` + +### Moving playback between the phone and the TV + +`startCastingFrom` connects, pauses the local player, and loads the stream on the receiver at the position playback had reached. The connection is established *before* local playback is touched, so a receiver that fails to connect leaves the phone playing exactly where it was; if the receiver connects but the stream fails to load, the session is torn down and the error is rethrown — so a load failure arrives as an exception, not as `false`. Local playback resumes only if it was playing when casting started; a source that was paused stays paused, at the right position. + +```dart +// Phone -> TV +try { + final started = await cast.startCastingFrom(playerController, device); + if (!started) showMessage(cast.lastError?.message ?? 'Could not connect'); +} on UnsupportedError catch (error) { + // DRM streams are refused here without a custom receiver + showMessage(error.message.toString()); +} on StateError catch (error) { + // The player has no data source yet — initialize it before casting + showMessage(error.message); +} catch (error) { + // The session came up but the stream would not load. It has already been + // torn down and local playback resumed. + showMessage('Could not start playback on the TV'); +} + +// TV -> phone, resuming at the receiver's position +await cast.stopCastingTo(playerController); +``` + +Keep the `FastPixPlayer` widget mounted while casting — hide it rather than removing it. Unmounting tears down the platform player, and `stopCastingTo` then has nothing to hand playback back to; it reports `FP_CAST_RESUME_UNAVAILABLE` and the session still ends cleanly. + +Both a session and the discovered device list can change from outside the app — the Google Home app, another sender, the TV powering off — so treat `stateStream` and the cast events as the single source of truth rather than assuming a command succeeded. + +### Controlling the receiver + +```dart +await cast.play(); +await cast.pause(); +await cast.stop(); // stops playback, keeps the session +await cast.seekTo(const Duration(minutes: 2)); +await cast.setVolume(0.4); // receiver hardware volume + +cast.remotePositionStream.listen((position) => setState(() => _position = position)); + +final isPlaying = cast.isRemotePlaying; +final position = cast.remotePosition; // last position the receiver reported +``` + +`remoteVolume` is what this app last asked for, not ground truth: the Cast plugin provides no callback for volume, so changes made from the TV remote, the Google Home app, or the phone's volume buttons are not reflected. + +To end the session entirely: + +```dart +await cast.disconnect(); // stops the receiver +await cast.disconnect(stopReceiver: false); // leaves it playing for other senders +``` + +### Subtitles while casting + +Subtitle tracks come from two places and are reported the same way: tracks declared in `FastPixPlayerDataSource.subtitles` are sent to the receiver on load, and tracks inside the HLS manifest are found by the receiver itself. Both appear in `textTracks` only once the receiver has reported a media status. + +```dart +cast.textTracksStream.listen((tracks) => setState(() => _tracks = tracks)); +cast.activeTextTrackStream.listen((id) => setState(() => _activeId = id)); + +await cast.selectTextTrack(track); // only tracks from `textTracks` +await cast.disableTextTrack(); // subtitles off +``` + +The selection is not recorded locally — the receiver confirms it in its next media status, which is also how a change made from a TV remote or another sender arrives. + +### Cast events + +Cast events are ordinary `FastPixPlayerEvent`s. Pass the player's event manager to the constructor to have them reach the same listeners as playback events: + +```dart +final cast = FastPixCastController(eventManager: playerController.eventManager); + +cast.addEventListener(FastPixPlayerEventTypes.castAvailable, (event) { + // A receiver became reachable — the moment to reveal a cast button +}); +cast.addEventListener(FastPixPlayerEventTypes.castStarted, (event) { /* ... */ }); +cast.addEventListener(FastPixPlayerEventTypes.castEnded, (event) { + final ended = event as FastPixCastEndedEvent; + // Fired whichever side ended the session, with the last remote position + debugPrint('${ended.device?.name} stopped at ${ended.position}'); +}); +cast.addEventListener(FastPixPlayerEventTypes.castError, (event) { + final error = event as FastPixCastErrorEvent; + debugPrint('${error.code}: ${error.message}'); +}); +``` + +| Event type | Class | Fired when | +| --- | --- | --- | +| `castAvailable` | `FastPixCastAvailableEvent` | The first receiver becomes reachable | +| `castStarted` | `FastPixCastStartedEvent` | A session becomes live | +| `castEnded` | `FastPixCastEndedEvent` | A session ends, whichever side ended it | +| `castError` | `FastPixCastErrorEvent` | Discovery, a session, or a remote load fails | + +`castError` deliberately does **not** extend `FastPixPlayerErrorEvent`: a cast failure is not a local playback failure, and the phone may still be playing perfectly. + +### Cast Error Handling + +The most recent failure stays on `cast.lastError`, so UI that mounts after the failure can still render it. Branch on the classification rather than on the message: + +```dart +final error = cast.lastError; +if (error != null) { + if (error.isPermissionRelated) { + await cast.openPermissionSettings(); // a permanently denied grant only Settings can fix + } else if (error.isContentUnsupported) { + // Never offer a retry — this content can never play on a receiver + } else if (error.isRetryable) { + // Trying again may work + } else if (error.isFatal) { + // Hide the cast button: casting is unusable on this device + } +} +``` + +An empty device list on Android 13+ is usually the nearby devices permission. `requiresNearbyDevicesPermission` tells you whether that explanation can even apply on the current device, so the UI does not report a permission problem that does not exist: + +```dart +if (await cast.requiresNearbyDevicesPermission) { + // Safe to suggest enabling "Nearby devices" for this app +} +``` + +#### Cast Error Codes + +| Code | Meaning | +| --- | --- | +| `FP_CAST_INIT_FAILED` | The Google Cast context could not be created | +| `FP_CAST_PLAY_SERVICES_UNAVAILABLE` | Google Play Services is missing or too old (Android) | +| `FP_CAST_NEARBY_PERMISSION_DENIED` | The Android 13+ `NEARBY_WIFI_DEVICES` permission was not granted | +| `FP_CAST_LOCAL_NETWORK_PERMISSION_DENIED` | The iOS local network permission was denied (reserved — iOS exposes no callback for it) | +| `FP_CAST_DISCOVERY_FAILED` | Discovery could not be started, stopped, or continued | +| `FP_CAST_DEVICE_UNAVAILABLE` | The chosen receiver is no longer in the discovered list | +| `FP_CAST_CONNECT_FAILED` | A session could not be established with the receiver | +| `FP_CAST_CONNECT_TIMEOUT` | The receiver did not establish a session before the timeout elapsed | +| `FP_CAST_SESSION_TAKEN` | The receiver is already running a session for another sender | +| `FP_CAST_SESSION_FAILED` | An established session failed after it had connected | +| `FP_CAST_DISCONNECT_FAILED` | The session could not be ended cleanly | +| `FP_CAST_DRM_UNSUPPORTED` | DRM protected content was loaded without a custom receiver configured | +| `FP_CAST_MEDIA_UNSUPPORTED` | The receiver refused the media: unsupported container or codec | +| `FP_CAST_LOAD_FAILED` | The load request failed for another reason | +| `FP_CAST_COMMAND_FAILED` | A transport command (play, pause, stop, seek, subtitle change) failed | +| `FP_CAST_VOLUME_FAILED` | A volume change was rejected by the receiver | +| `FP_CAST_RESUME_UNAVAILABLE` | Casting stopped but local playback could not resume | + +### DRM on Chromecast + +Chromecast receivers speak Widevine only — never FairPlay — and Google's Default Media Receiver cannot perform a license request at all. Loading a DRM protected source without a custom receiver is refused: `loadMedia` throws `UnsupportedError` and emits `FP_CAST_DRM_UNSUPPORTED`. + +The refusal happens at load time, not before connecting. `startCastingFrom` connects to the receiver and pauses local playback first, so a DRM source tears the fresh session down again on the way out. Check for DRM yourself before offering the cast button if you would rather the receiver were never woken. + +With a custom receiver configured through `appId`, the SDK sends the license details in the media's `customData`, using the Widevine license URL derived from the playback ID even when the phone plays the same title locally through FairPlay: + +```json +{ + "licenseUrl": "https://.../drm/license/widevine/{playbackId}?token=...", + "protectionSystem": "widevine" +} +``` + +Your receiver application reads `loadRequest.media.customData.licenseUrl` and configures its playback manager with it. The FastPix license endpoint carries its token as a query parameter, so the receiver needs no custom headers. + +### Segment format + +A Cast receiver is a web player and has to know how the segments are packaged before it can build a playback pipeline. When the manifest does not make that obvious the receiver assumes MPEG-TS, and an fMP4/CMAF stream then connects, displays its title, and never starts playing — with no error on either side. + +If casting connects but nothing plays, this is almost always the cause: + +```dart +FastPixCastController(segmentFormat: FastPixCastSegmentFormat.fmp4); +``` + +Modern packaging is fMP4/CMAF, and any stream serving both Widevine and FairPlay from one source — which is how FastPix DRM works — is CMAF. `FastPixCastSegmentFormat.auto` (the default) sends no hint and is correct for plain MPEG-TS streams. + +### What does not survive the trip + +Because the receiver fetches and renders the stream itself, several data source options have no effect while casting: + +- `headers` are dropped — authentication has to be in the URL, which the FastPix playback token already is. +- Resolution hints (`resolution`, `minResolution`, `maxResolution`, `renditionOrder`) are sent as URL parameters, but adaptive switching is then the receiver's decision. +- `cacheEnabled`, `loop` and `endAt` are local player behaviours with no receiver equivalent. + +### Lifecycle + +`dispose()` releases every subscription and stream the controller opened but deliberately **does not end a live session** — a viewer who started casting expects the TV to keep playing when they leave the player screen. Call `disconnect()` first if the session should stop with the screen. For the same reason, hold the cast controller at app scope rather than rebuilding it per screen. + +```dart +await cast.stopDiscovery(); +await cast.dispose(); +``` + ## Custom Domain ### Public Media @@ -403,7 +821,7 @@ The main data source class that handles streaming configuration: - `drmConfiguration`: DRM configuration for protected media. Requires `token` to be set as well - `streamType`: Set to `StreamType.onDomand | StreamType.live` for live streams - `headers`: Optional HTTP headers for authentication -- `cacheEnabled`: Enable/disable video caching (always disabled for DRM sources) +- `cacheEnabled`: Enable/disable the player's playback cache. Honoured on Android, including for DRM sources; ignored on iOS HLS, where it cannot coexist with AVFoundation's single resource-loader slot. This flag covers caching *during* playback only — caching a source ahead of time is a separate API, `FastPixPrecacheManager` - `loop`: Enable/disable video looping - `qualityControl`: Quality control parameters - `showSubtitles`: Whether to show subtitles by default @@ -426,14 +844,15 @@ DRM configuration for protected media: - `drmToken` (required): JWT authorizing access to the FastPix DRM license server ([how to generate](https://fastpix.com/docs/web-player/play-drm-protected-content#how-to-generate-drm-tokens)) #### Optional Parameters -- `drmType`: DRM system to use. Defaults to FairPlay on iOS and Widevine on Android +- `drmType`: DRM system to use. Defaults to FairPlay on iOS and Widevine everywhere else - `headers`: Additional headers sent with the license request +- `secureScreen`: Block screenshots and screen recording while this source plays (default `true`). Android only, and window wide — see [Screen capture protection](#screen-capture-protection) #### Members - `resolvedDrmType`: DRM system for the current platform, honouring an explicit `drmType` - `licenseUrl(playbackId)`: License server URL for the playback ID - `certificateUrl(playbackId)`: FairPlay application certificate URL, `null` for DRM systems that do not use one -- `validate(playbackId, hasPlaybackToken)`: Fail fast with a `FastPixDrmException` when the configuration cannot produce a successful license request +- `validate({required playbackId, required hasPlaybackToken})`: Fail fast with a `FastPixDrmException` when the configuration cannot produce a successful license request. Both parameters are named - `copyWith()`: Create a copy with updated values ### FastPixDrmException @@ -460,6 +879,87 @@ Advanced quality control parameters: #### Rendition Control - `renditionOrder`: Quality selection order (default_, asc, desc) +### FastPixCastController + +Drives Chromecast playback for a FastPix stream. + +#### Constructor Parameters +- `appId`: Cast application ID of the receiver to look for. Defaults to Google's Default Media Receiver +- `stopCastingOnAppTerminated`: Whether the receiver stops playing when the app is terminated (default `true`) +- `segmentFormat`: How the HLS streams being cast are packaged (`auto`, `fmp4`, `mpegTs`) +- `verbose`: Print a trace of the cast handshake, tagged `[FastPixCast]` +- `eventManager`: Event manager to dispatch cast events through. Pass `player.eventManager` to share listeners with playback events + +#### Lifecycle +- `initialize()`: Initialize the Cast context. Repeat calls are a no-op; settles on `unavailable` on unsupported platforms instead of throwing +- `startDiscovery()` / `stopDiscovery()`: Start and stop scanning for receivers +- `dispose()`: Release subscriptions and streams. Does **not** end a live session + +#### Sessions +- `connect(device, {timeout})`: Start a session and wait until it is established. Returns whether it connected +- `disconnect({stopReceiver = true})`: End the current session +- `startCastingFrom(player, device)`: Move playback from the local player to the receiver, continuing where it left off. Returns `false` when the receiver does not connect; throws `StateError` when the player has no data source, `UnsupportedError` for DRM sources without a custom receiver, and rethrows a load failure after resuming locally +- `stopCastingTo(player)`: Move playback back from the receiver to the local player + +#### Media +- `loadMedia(dataSource, {startAt, autoPlay})`: Load a stream on the connected receiver. Throws `StateError` when no session is connected and `UnsupportedError` for DRM sources without a custom receiver +- `play()`, `pause()`, `stop()`, `seekTo(position)`: Remote transport control +- `setVolume(volume)`: Set the receiver's device volume (0.0–1.0) +- `selectTextTrack(track)` / `disableTextTrack()`: Change the subtitle track on the receiver + +#### State +- `state` / `stateStream`: Current `FastPixCastState` and its changes +- `devices` / `devicesStream`: Discovered receivers +- `connectedDevice`: The receiver currently playing, or `null` +- `isConnected`, `isRemotePlaying`, `hasCustomReceiver` +- `remotePosition` / `remotePositionStream`: Position reported by the receiver +- `remoteVolume` / `remoteVolumeStream`: Volume as this app last set it — external changes are invisible +- `textTracks` / `textTracksStream`: Subtitle tracks the receiver is offering +- `activeTextTrack`: The selected subtitle track, or `null` when off +- `activeTextTrackStream`: The selected track's **ID**, not the track itself, or `null` each time subtitles go off +- `remotePositionStream`: Does not replay its latest value to a new listener — seed your UI from `remotePosition` when you subscribe +- `lastError`: Most recent `FastPixCastErrorEvent`, or `null` + +#### Permissions +- `requiresNearbyDevicesPermission`: Whether this device gates discovery behind the Android 13+ nearby devices permission +- `openPermissionSettings()`: Open the system settings page for this app + +#### Listeners +- `addEventListener(type, listener)` / `removeEventListener(type, listener)` +- `addGlobalListener(listener)` / `removeGlobalListener(listener)` + +### FastPixCastDevice + +A receiver discovered on the local network: + +- `id`: Stable identifier, used to connect to it +- `name`: Name the user gave the device, e.g. "Living Room TV" +- `modelName`: Hardware model, e.g. "Chromecast" +- `statusText`: Text the receiver is currently displaying, when it reports any +- `isOnLocalNetwork`: Whether the receiver is on the same local network + +### FastPixCastTextTrack + +A subtitle or caption track the receiver is offering: + +- `id`: Receiver-assigned track ID, used to select it +- `label`: Label to show, falling back to the language code and then the track ID +- `languageCode`: RFC 5646 language code, when the receiver reported one +- `isClosedCaption`: Whether the track is closed captions rather than plain subtitles + +### FastPixCastErrorEvent + +Emitted for every cast failure: + +- `errorCode`: Normalized `FastPixCastErrorCode` +- `code`: Stable string code, e.g. `FP_CAST_CONNECT_TIMEOUT` +- `message`: Human readable, actionable description +- `underlyingError`: Raw platform error string, when the failure came from the Cast SDK +- `isFatal`: Casting is unusable until the user changes something outside the app — hide the cast button +- `isPermissionRelated`: Fixable from the system settings app; pair with `openPermissionSettings()` +- `isContentUnsupported`: This content can never play on a receiver — do not offer a retry +- `isRetryable`: The same action may succeed if simply tried again + ### Widgets #### FastPixPlayer @@ -495,6 +995,21 @@ DRM related properties: - `widevine`: Widevine, used on Android - `fairplay`: FairPlay, used on iOS +#### FastPixCastState +- `unavailable`: Casting cannot be used on this device at all +- `noDevices`: Cast is ready but no receiver has been discovered yet +- `devicesFound`: At least one receiver is available — show the cast button +- `connecting`: A session is being established +- `connected`: A session is live; the receiver is playing the stream +- `error`: Discovery or the session failed; the reason is on `lastError` + +Extension getters for gating cast UI: `canCast`, `isCasting`, `hasSession`. + +#### FastPixCastSegmentFormat +- `auto`: Send no hint and let the receiver work it out (default) +- `fmp4`: fMP4 / CMAF segments +- `mpegTs`: Classic MPEG-TS segments + ## Additional Information FastPix Player is designed specifically for streaming content from staging.metrix.com and other streaming services. It automatically constructs the correct streaming URLs based on your playback ID, custom domain, and chosen format, ensuring optimal performance and compatibility. @@ -510,6 +1025,7 @@ The controller-based API ensures predictable behavior by centralizing all data s - **Custom Domains**: Support for custom streaming domains - **Authentication**: Token-based authentication - **DRM**: Widevine and FairPlay playback through the FastPix license server +- **Chromecast**: Discovery, session management, and handoff between local and receiver playback - **Error Handling**: Comprehensive error management For issues, feature requests, or contributions, please visit the project repository. diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000..9497e46 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,69 @@ +group 'com.fastpix.videoplayer' +version '1.0' + +buildscript { + ext.kotlin_version = '2.1.0' + repositories { + google() + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:8.7.3' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + } +} + +rootProject.allprojects { + repositories { + google() + mavenCentral() + } +} + +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' + +android { + namespace 'com.fastpix.videoplayer' + // 36 because media3 1.10.x (pulled in by better_player_plus) requires + // consumers to compile against 36 or later. + compileSdk 36 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } + + defaultConfig { + minSdk 21 + } +} + +dependencies { + // Reaches BetterPlayerCache — the SimpleCache singleton playback reads from. + // A precache that writes anywhere else is invisible to the player, however + // complete it looks, so the writer has to use the reader's cache instance. + // + // `compileOnly` because better_player_plus is already a plugin in every host + // app, so the classes are present at runtime; packaging a second copy would + // duplicate them. Flutter exposes each plugin as a Gradle subproject, which + // is what makes `project(':better_player_plus')` resolvable. + // + // This couples us to an internal class in another package. If a future + // better_player_plus renames `BetterPlayerCache` or moves its namespace, + // this fails to compile — loudly, which is the right failure mode. + compileOnly project(':better_player_plus') + + // media3, for CacheWriter/DataSpec. Declared explicitly because + // `compileOnly` does not expose the transitive dependencies of + // `project(':better_player_plus')`. + // + // Pinned to the version better_player_plus 1.0.8 resolves. A mismatch here + // compiles but can fail at runtime on a signature change, so this moves + // whenever the engine's media3 does. + compileOnly 'androidx.media3:media3-datasource:1.10.0' + compileOnly 'androidx.media3:media3-common:1.10.0' +} diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000..db0a110 --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1 @@ +rootProject.name = 'fastpix_video_player' diff --git a/android/src/main/AndroidManifest.xml b/android/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/android/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/android/src/main/kotlin/com/fastpix/videoplayer/FastPixMediaCacheWarmer.kt b/android/src/main/kotlin/com/fastpix/videoplayer/FastPixMediaCacheWarmer.kt new file mode 100644 index 0000000..dd7e9b9 --- /dev/null +++ b/android/src/main/kotlin/com/fastpix/videoplayer/FastPixMediaCacheWarmer.kt @@ -0,0 +1,159 @@ +package com.fastpix.videoplayer + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.media3.common.C +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.CacheDataSink +import androidx.media3.datasource.cache.CacheWriter +import androidx.media3.datasource.FileDataSource +import uz.shs.better_player_plus.BetterPlayerCache +import java.util.concurrent.Executors + +/** + * Writes one HLS master playlist into the cache the player reads from, now. + * + * ## Why this exists rather than calling better_player's own `preCache` + * + * Two reasons, both measured on device before this was written. + * + * **1 — `preCache` is deferred.** It builds a `OneTimeWorkRequest` and hands it + * to `WorkManager` (`BetterPlayer.kt:834-839`), a batching scheduler that runs + * work when the system feels like it — often minutes later, often only once the + * app is backgrounded. Half the value of warming a playlist is the *hot + * connection* it leaves to the host moments before playback starts, and a + * deferred job cannot deliver that. This runs immediately, on a background + * thread, under the caller's control. + * + * **2 — `preCache` asks for the wrong byte range.** It builds + * `DataSpec(uri, 0, preCacheSize)` — a *bounded* request. A master playlist is + * ~3 KB and is served without a `Content-Length`, so a request bounded at, say, + * 512 KB can never be satisfied and nothing is committed. The job still reports + * success, because `result.success(null)` sits outside the guard that decides + * whether anything was enqueued at all. Here the length is [C.LENGTH_UNSET], + * which means "read to end of stream" — the correct way to cache a whole small + * file. + * + * ## Why the master playlist and nothing else + * + * Because it is the only URL that is stable. FastPix re-signs segment and + * variant URLs on every manifest resolution — both the path prefix and the + * query — and media3 keys HLS cache entries by URI, so a cached segment is + * written under a key that will never be requested again. The master's URL is + * `https://stream.fastpix.com/{playbackId}.m3u8`: its path *is* the playback + * ID, so the default URI-derived key is already a stable per-asset key. + * + * Deliberately no custom cache key is set, and that is forced rather than + * chosen. media3 keys HLS entries by request URI, and the only override + * available through the engine (`MediaItem.customCacheKey`) is honoured for + * progressive sources only — `HlsMediaSource` ignores it. Setting a key here + * would make this writer use one derivation while playback used another, which + * turns a working cache into a guaranteed miss. + * + * The cost of that constraint: a signed URL carries `?token=`, which is + * part of the key. If the token is re-resolved between this warm and playback, + * the entry written here is never found. Precaching therefore pays off for + * sources with a stable URL; for rotating tokens, preloading is the mechanism + * that survives, because it keys by playback ID in Dart. + */ +internal object FastPixMediaCacheWarmer { + + /** + * Log tag, matching the Dart side so one filter covers both: + * `adb logcat | grep -E "preloading|precaching"`. + */ + private const val TAG = "precaching" + + /** Small pool: these run alongside playback and must not outbid it. */ + private val executor = Executors.newFixedThreadPool(2) + + /** + * Cache [url] into [BetterPlayerCache]'s `SimpleCache`. + * + * [maxCacheSize] must match what playback configures. + * `BetterPlayerCache.createCache` memoises its instance on first use, so a + * differing size can hand back a *different* cache and the writer would + * populate one the reader never opens. + * + * [onResult] is invoked with the bytes written, or an error message. It is + * called on a background thread; the caller marshals to main. + */ + fun warm( + context: Context, + url: String, + headers: Map, + maxCacheSize: Long, + maxCacheFileSize: Long, + onResult: (bytesWritten: Long?, error: String?) -> Unit + ) { + executor.execute { + try { + val uri = Uri.parse(url) + if (uri.scheme?.startsWith("http") != true) { + onResult(null, "only http(s) sources can be cached: $url") + return@execute + } + + Log.d(TAG, "native: warming $url") + + val cache = BetterPlayerCache.createCache(context, maxCacheSize) + ?: run { + Log.w(TAG, "native: BetterPlayerCache returned no cache instance") + onResult(null, "BetterPlayerCache returned no cache instance") + return@execute + } + // The cache identity matters as much as the bytes: BetterPlayerCache + // memoises on first use, so a differing maxCacheSize can hand back a + // *different* SimpleCache and we would fill one playback never opens. + Log.d( + TAG, + "native: cache instance=${System.identityHashCode(cache)} " + + "maxCacheSize=$maxCacheSize (must match playback's, or the " + + "reader opens a different cache)" + ) + + val upstream = DefaultHttpDataSource.Factory() + .setAllowCrossProtocolRedirects(true) + .setDefaultRequestProperties(headers) + + val cacheDataSource = CacheDataSource( + cache, + upstream.createDataSource(), + FileDataSource(), + CacheDataSink(cache, maxCacheFileSize), + // No IGNORE_CACHE_FOR_UNSET_LENGTH_REQUESTS: a playlist is + // served without a Content-Length, and refusing to cache + // unset-length responses is exactly what we must not do. + CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR, + null + ) + + // LENGTH_UNSET, not a byte ceiling — read to EOF. This is the + // difference between committing a 3 KB playlist and committing + // nothing at all. + val dataSpec = DataSpec.Builder() + .setUri(uri) + .setPosition(0) + .setLength(C.LENGTH_UNSET.toLong()) + .build() + + var written = 0L + CacheWriter(cacheDataSource, dataSpec, null) { _, bytesCached, _ -> + written = bytesCached + }.cache() + + Log.d(TAG, "native: wrote $written bytes for $url") + onResult(written, null) + } catch (error: Throwable) { + Log.w(TAG, "native: warm failed for $url — playback is unaffected", error) + // Never rethrow: a warm-up that surfaces errors turns a latency + // optimisation into a new failure mode. The caller reports it on + // the precache channel, never the playback one. + onResult(null, error.toString()) + } + } + } +} diff --git a/android/src/main/kotlin/com/fastpix/videoplayer/FastPixVideoPlayerPlugin.kt b/android/src/main/kotlin/com/fastpix/videoplayer/FastPixVideoPlayerPlugin.kt new file mode 100644 index 0000000..225e733 --- /dev/null +++ b/android/src/main/kotlin/com/fastpix/videoplayer/FastPixVideoPlayerPlugin.kt @@ -0,0 +1,99 @@ +package com.fastpix.videoplayer + +import android.content.Context +import android.os.Handler +import android.os.Looper +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel + +/** + * Native side of `FastPixPrecacheManager`. + * + * Deliberately small. Everything the SDK does beyond writing bytes into the + * player's cache — deciding *what* is worth caching, refusing DRM and live + * sources, bookkeeping, events — stays in Dart, where it is testable without a + * device. This exists only because the component that writes the cache has to + * be the one that reads it: bytes fetched from Dart land in Dart's HTTP client + * and ExoPlayer never consults them. + */ +class FastPixVideoPlayerPlugin : FlutterPlugin, MethodChannel.MethodCallHandler { + + private lateinit var channel: MethodChannel + private var context: Context? = null + private val main = Handler(Looper.getMainLooper()) + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + channel = MethodChannel(binding.binaryMessenger, CHANNEL) + channel.setMethodCallHandler(this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + channel.setMethodCallHandler(null) + context = null + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + when (call.method) { + METHOD_WARM_MANIFEST -> warmManifest(call, result) + else -> result.notImplemented() + } + } + + private fun warmManifest(call: MethodCall, result: MethodChannel.Result) { + val appContext = context + val url = call.argument(ARG_URL) + + // Fail loudly rather than reporting success on a no-op. better_player's + // own preCache calls result.success(null) outside the guard that decides + // whether anything was enqueued, so a missing context or URL silently + // reports "cached" — which is how a broken cache stays invisible. + if (appContext == null || url.isNullOrEmpty()) { + result.error( + "unavailable", + "no application context or url (context=${appContext != null}, url=$url)", + null + ) + return + } + + val headers = call.argument>(ARG_HEADERS) ?: emptyMap() + val maxCacheSize = call.argument(ARG_MAX_CACHE_SIZE)?.toLong() + ?: DEFAULT_MAX_CACHE_SIZE + val maxCacheFileSize = call.argument(ARG_MAX_CACHE_FILE_SIZE)?.toLong() + ?: DEFAULT_MAX_CACHE_FILE_SIZE + + FastPixMediaCacheWarmer.warm( + context = appContext, + url = url, + headers = headers, + maxCacheSize = maxCacheSize, + maxCacheFileSize = maxCacheFileSize + ) { bytesWritten, error -> + main.post { + if (error != null) { + result.error("warm_failed", error, null) + } else { + // The byte count is the honest signal: a "success" that + // wrote zero bytes is not a success, and only the caller + // can see the difference. + result.success(bytesWritten ?: 0L) + } + } + } + } + + private companion object { + const val CHANNEL = "fastpix_video_player/precache" + const val METHOD_WARM_MANIFEST = "warmManifest" + + const val ARG_URL = "url" + const val ARG_HEADERS = "headers" + const val ARG_MAX_CACHE_SIZE = "maxCacheSize" + const val ARG_MAX_CACHE_FILE_SIZE = "maxCacheFileSize" + + const val DEFAULT_MAX_CACHE_SIZE = 100L * 1024 * 1024 + const val DEFAULT_MAX_CACHE_FILE_SIZE = 10L * 1024 * 1024 + } +} diff --git a/example/README.md b/example/README.md index 20e078e..1ab899c 100644 --- a/example/README.md +++ b/example/README.md @@ -1,8 +1,9 @@ # fastpix_player_example -A runnable demo of the [`fastpix_video_player`](../) package. It plays a FastPix -HLS stream from a playback ID, supports private (token protected) and DRM -protected media, and streams the player's events into an on-screen log. +A runnable demo of the [`fastpix_video_player`](../) package, built as a small +streaming app rather than a single playback form. It browses a catalog of +streams, plays public, private and DRM protected media, casts to Chromecast, and +puts the preloading and precaching machinery on screen so you can watch it work. ## Running @@ -15,133 +16,158 @@ flutter pub get flutter run ``` -## Using the demo +It starts with a seeded catalog of public FastPix playback IDs, so there is +something to play immediately. To use your own instead: -1. **Playback ID** — paste a playback ID that has reached the `ready` status in - the [FastPix Dashboard](https://dashboard.fastpix.com). -2. **Token** — only needed for private and DRM protected media. Leave it empty - for public streams. -3. **DRM protected** — leave this off for ordinary streams. Turning it on - reveals the **DRM token** field. DRM is opt-in on purpose: routing clear - media through Widevine/FairPlay never plays, so a leftover token in the field - cannot silently turn a plain playback ID into a DRM load. -4. **DRM token** — the JWT authorizing the license request. When the token was - generated with the **DRM License** feature enabled, the same value works for - both the token and DRM token fields. -5. **Load & play** — builds the data source and initializes the controller. - -Below the player the demo shows the constructed stream URL and a live log of -every player event. DRM failures are logged with their error code and actionable -message; an invalid DRM setup also surfaces as a snackbar instead of an endless -spinner. - -## Usage example - -The core of [`lib/main.dart`](lib/main.dart), condensed: - -```dart -import 'package:fastpix_video_player/fastpix_video_player.dart'; -import 'package:flutter/material.dart'; - -// Tear down any previous playback before starting a new one. -await _controller?.dispose(); - -final dataSource = FastPixPlayerDataSource.hls( - playbackId: playbackId, - // Only for private / DRM media - token: token.isEmpty ? null : token, - // Only when DRM is explicitly switched on — a leftover token must not - // silently make an ordinary stream a DRM one. - drmConfiguration: !drmEnabled || drmToken.isEmpty - ? null - : FastPixPlayerDrmConfiguration(drmToken: drmToken), - title: 'Sample HLS Stream', - videoData: VideoDetailsData(videoId: playbackId, title: 'Sample HLS'), -); - -// workSpaceId / viewerId / beaconUrl feed the FastPix metrics SDK. -final configuration = FastPixPlayerConfiguration( - 'demo-workspace', - 'demo-viewer', - 'metrix.ws.fastpix.io', - controlsConfiguration: const FastPixPlayerControlsConfiguration( - autoPlay: true, - enableRetry: true, - ), -); - -final controller = FastPixPlayerController(); -controller.addGlobalListener((event) { - // DRM failures carry a code and an actionable message; everything else - // is logged by type. - if (event is FastPixPlayerDrmErrorEvent) { - debugPrint('${event.type} [${event.code}] ${event.message}'); - } else { - debugPrint(event.type); - } -}); - -try { - await controller.initialize( - dataSource: dataSource, - configuration: configuration, - ); -} on FastPixDrmException catch (error) { - // The controller already emitted the error event; surface it here too so a - // bad DRM setup is visible instead of an endless spinner. - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('DRM: ${error.message}')), - ); -} +```bash +flutter run --dart-define=FASTPIX_PLAYBACK_IDS=id-one,id-two,id-three ``` -And in `build`: +Command line IDs take precedence over the stored catalog and replace it. The +catalog otherwise persists to `demo_catalog.json` in the application support +directory, so streams you add survive a restart. -```dart -FastPixPlayer( - // No key needed: the player re-initializes itself when it is given a - // different controller. Keying off the stream URL would miss a changed DRM - // token, which does not appear in that URL. - controller: controller, - height: 220, -) -``` +## Using the demo + +**Home** is a browse screen: a featured hero and three rails, *Continue +watching*, *Live now* and *On demand*. Every poster carries a small preload +status pill. Tapping a poster opens the watch screen with that rail as the +playlist; long pressing opens a menu to edit, precache or remove the stream. +The `+` button adds one. + +**Watch** plays the stream and, below it, exposes the parts usually invisible: + +- *Up next*, with previous and next controls and an **Autoplay next** switch. + Advancing swaps the source in place rather than pushing a new screen. +- A precache panel with its status and the exact byte count written. +- A warm start badge reading `WARM START · warmed in Nms` or `COLD START`, which + is the single clearest signal that preloading is doing anything. +- A preload event feed, and detail rows for playback ID, host, stream type, DRM + and subtitles, ending in the fully resolved stream URL. + +The seeded streams are public video on demand with no token and no DRM, so the +*Live now* rail stays empty until you add a stream with the live switch on. + +### Adding a stream + +The add and edit sheet takes a playback ID, an optional title, a stream host +(blank uses `stream.fastpix.com`), a token for private or DRM media, a live +switch, a DRM switch that reveals the DRM token field, and an external WebVTT +subtitle URL with its label and language. + +DRM is opt in on purpose. Routing clear media through Widevine or FairPlay never +plays, so a leftover token cannot silently turn an ordinary playback ID into a +DRM load. When your token was generated with the DRM License feature enabled, +the same value works in both the token and DRM token fields. + +## Chromecast + +The cast glyph appears in the player's own control bar once a receiver is found. +Tapping it opens a device picker; choosing a device moves playback to the TV at +the position it had reached locally. + +While casting, the video is replaced by a remote control surface with a +scrubber, skip and play controls, receiver volume, and a subtitle picker fed by +the tracks the receiver reports. It renders inline and in fullscreen. Stopping +brings playback back to the phone. + +The demo is configured with a FastPix custom receiver, which is what makes DRM +casting possible at all. Google's Default Media Receiver cannot perform a +license request, so a protected stream is refused without one. None of the +seeded streams are protected, so this only matters once you add one. + +A diagnostics sheet opens from the tune icon on the home screen and the +**Diagnostics** chip on the watch screen, for when casting misbehaves: + +- Current cast state, and a rescan button. +- Devices found, with a count and the connected one marked. +- The last error. When Android's nearby devices permission was denied, it + offers a shortcut to the system settings page, since that is the one failure + the user has to fix outside the app. +- A live event log, with a clear button. +- A **fMP4 / CMAF segments** toggle, for when the receiver never starts + playing. It is disabled during a session, because changing it rebuilds the + controller. + +## Preloading and precaching + +The demo runs both, with different settings in each place, which is the point: + +| | Where | Strategy | Window | +| --- | --- | --- | --- | +| Home | Whole catalog | `network` | 3 | +| Watch | Neighbours in the queue | `player` | 4 | + +The home screen warms the connection and manifest broadly, because it holds no +decoders and costs little. The watch screen warms whole players for the +immediate neighbours, so the next or previous item starts instantly. Home stops +warming while the watch screen is on top, so it cannot evict the warms that are +about to be used, and cast awareness is wired up so no local decoder is spent +while playback is on a receiver. + +Precaching writes the master playlist only, from the poster menu or the panel on +the watch screen. The panel reports the bytes actually written, which is the +honest signal: a platform can report success and write nothing. On Android you +can confirm bytes landed with `adb logcat | grep CacheWorker`. ## What it demonstrates -- Building an HLS data source with `FastPixPlayerDataSource.hls(...)`, including - `drmConfiguration` for protected media. -- Configuring FastPix metrics through `FastPixPlayerConfiguration` (workspace - ID, viewer ID, beacon URL) and `VideoDetailsData`. -- Listening to every event with `controller.addGlobalListener(...)` and handling - `FastPixPlayerDrmErrorEvent` separately. +- Browsing and playing with `FastPixPlayerDataSource.hls(...)`, including + `drmConfiguration` for protected media and external subtitle tracks. +- Sharing one `FastPixPlayerConfiguration` between preloading and playback, + which is what makes a warmed player adoptable. A mismatch is refused. +- `FastPixPreloadManager` under both strategies, with status per source and the + adoption result surfaced on screen. +- `FastPixPrecacheManager.precacheManifest` with byte accounting. +- `FastPixCastController` end to end: discovery, session handover with + `startCastingFrom` / `stopCastingTo`, remote transport, volume, subtitle + selection, and normalized error codes. +- Warming playback hosts at app start with `warmPlaybackHostsFor`, derived from + the catalog so custom domains are warmed at the host they are played from. +- Listening with `controller.addGlobalListener(...)` and handling + `FastPixPlayerDrmErrorEvent` separately from ordinary events. - Catching `FastPixDrmException` from `controller.initialize(...)` so a bad DRM - configuration is reported immediately. -- Disposing the previous controller before starting a new playback, and handing - `FastPixPlayer` a new controller to restart playback — no widget key is - needed, which matters because a changed DRM token does not change the stream - URL. + configuration is reported immediately instead of as an endless spinner. + +To watch the SDK's own decisions, including every skip and refusal: + +```bash +flutter run | grep -E "preloading|precaching" +``` + +The line worth looking for is `ADOPTED`. ## Platform setup ### Android -Streaming needs the internet permission, already present in -`android/app/src/main/AndroidManifest.xml`: +The example declares, in `android/app/src/main/AndroidManifest.xml`: ```xml + + + + ``` +plus the Cast options provider and the media notification service inside +``. Only the internet permission is needed for plain playback; the +rest are for Chromecast. The runtime nearby devices request is made by the SDK +from `startDiscovery()`. + DRM playback on Android uses Widevine and is fully supported. ### iOS -The Runner project targets **iOS 13.0**, which is what `better_player_plus` -requires. In your own app, set the same minimum in `ios/Podfile`: +The example's Runner project targets **iOS 15.0**. The package itself declares a +floor of **iOS 12.0** in its podspec, and that is the real minimum for your own +app; the example simply targets something newer. Set it in `ios/Podfile`: ```ruby -platform :ios, '13.0' +platform :ios, '12.0' ``` and make sure `IPHONEOS_DEPLOYMENT_TARGET` in the Xcode project is not lower. @@ -152,21 +178,23 @@ Podfile: cd ios && pod install ``` +Chromecast discovery needs `NSLocalNetworkUsageDescription`, +`NSBluetoothAlwaysUsageDescription` and an `NSBonjourServices` entry listing +both `_googlecast._tcp` and the receiver specific service. The example's +`Info.plist` has all three. Streaming over HTTPS needs no App Transport Security +exception, and the example ships without one. + Two iOS behaviours are worth knowing about: -- **Caching is disabled for HLS on iOS.** `better_player` serves cached bytes - through an `AVAssetResourceLoader`, which can stand in for a single file but - not for a playlist that resolves to many segment URLs. With it enabled - AVFoundation rejects an otherwise healthy stream with `CoreMediaErrorDomain` - `-12642`, so the package turns the cache off for iOS HLS regardless of - `cacheEnabled`. -- **FairPlay does not work yet.** `better_player_plus` routes FairPlay through - an EZDRM specific resource loader that rewrites the license URL, so FastPix - FairPlay playback does not currently work on iOS without patching the plugin. - Toggling DRM on in the demo on an iOS device will therefore fail at the - license step; use Android to try DRM playback. - -Streaming over HTTPS needs no App Transport Security exception, and the example -ships without one. +- **FairPlay works, with nothing to install.** The package ships a resource + loader patch that installs itself when the plugin registers, which is what + lets FairPlay reach the FastPix license server. Earlier versions needed a + manual edit to the cached engine; that step is gone. +- **Caching is disabled for HLS on iOS.** The engine's cache serves bytes + through a local proxy, and that proxy does not survive a signed FastPix URL: + an unprotected stream fails outright with `CoreMediaErrorDomain -12642`. The + package therefore ignores `cacheEnabled` for iOS HLS. Precaching is separately + refused for DRM on iOS, because FairPlay already owns the asset's single + resource loader slot. For the full API, see the [package README](../README.md). diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 9c6cafd..f7bdf23 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,21 @@ + + + + + + + + + + + + + +