Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
592 changes: 554 additions & 38 deletions README.md

Large diffs are not rendered by default.

69 changes: 69 additions & 0 deletions android/build.gradle
Original file line number Diff line number Diff line change
@@ -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'
}
1 change: 1 addition & 0 deletions android/settings.gradle
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
rootProject.name = 'fastpix_video_player'
1 change: 1 addition & 0 deletions android/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
Original file line number Diff line number Diff line change
@@ -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=<JWT>`, 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<String, String>,
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())
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String>(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<Map<String, String>>(ARG_HEADERS) ?: emptyMap()
val maxCacheSize = call.argument<Number>(ARG_MAX_CACHE_SIZE)?.toLong()
?: DEFAULT_MAX_CACHE_SIZE
val maxCacheFileSize = call.argument<Number>(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
}
}
Loading