diff --git a/.github/workflows/pr_title.yml b/.github/workflows/pr_title.yml
index 737a8c09..a9b45bc6 100644
--- a/.github/workflows/pr_title.yml
+++ b/.github/workflows/pr_title.yml
@@ -26,6 +26,7 @@ jobs:
llc
ui
repo
+ thumb
requireScope: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -39,7 +40,8 @@ jobs:
scopes: |
{
"llc": "packages/stream_core",
- "ui": "packages/stream_core_flutter"
+ "ui": "packages/stream_core_flutter",
+ "thumb": "packages/stream_thumbnail"
}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index b349bd38..511c01eb 100644
--- a/.gitignore
+++ b/.gitignore
@@ -89,6 +89,10 @@
**/ios/ServiceDefinitions.json
**/ios/Runner/GeneratedPluginRegistrant.*
+# Swift Package Manager related
+**/ios/**/.build/
+**/ios/**/.swiftpm/
+
# macOS
**/Flutter/ephemeral/
**/Pods/
diff --git a/melos.yaml b/melos.yaml
index 725072b5..9e7e5b3c 100644
--- a/melos.yaml
+++ b/melos.yaml
@@ -32,6 +32,7 @@ command:
markdown: ^7.3.0
meta: ^1.15.0
mime: ^2.0.0
+ plugin_platform_interface: ^2.1.8
rxdart: ^0.28.0
shimmer: ^3.0.0
stream_core: ^0.4.0
diff --git a/packages/stream_thumbnail/CHANGELOG.md b/packages/stream_thumbnail/CHANGELOG.md
new file mode 100644
index 00000000..d0bd041d
--- /dev/null
+++ b/packages/stream_thumbnail/CHANGELOG.md
@@ -0,0 +1,3 @@
+## 0.0.1
+
+* Initial release.
diff --git a/packages/stream_thumbnail/LICENSE b/packages/stream_thumbnail/LICENSE
new file mode 100644
index 00000000..d2e93f97
--- /dev/null
+++ b/packages/stream_thumbnail/LICENSE
@@ -0,0 +1,22 @@
+MIT License
+
+Copyright (c) 2026 STREAM.IO, INC.
+Copyright (c) 2019 John Zhong
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/packages/stream_thumbnail/README.md b/packages/stream_thumbnail/README.md
new file mode 100644
index 00000000..0acbd9d9
--- /dev/null
+++ b/packages/stream_thumbnail/README.md
@@ -0,0 +1,70 @@
+# Stream Thumbnail
+
+## Introduction
+
+A Flutter plugin for creating a thumbnail image from a video. Give it a local file path or a video URL and it returns the thumbnail as bytes or as a saved image file. Works on Android, iOS, and web.
+
+## Installation
+
+Add the following to your `pubspec.yaml` and replace `[version]` with the latest version:
+
+```yaml
+dependencies:
+ stream_thumbnail: ^[version]
+```
+
+## Usage
+
+```dart
+import 'package:stream_thumbnail/stream_thumbnail.dart';
+```
+
+### Bytes
+
+Get the thumbnail as in-memory bytes — ideal for `Image.memory`:
+
+```dart
+final bytes = await StreamThumbnail.thumbnailData(
+ video: 'https://example.com/video.mp4',
+ imageFormat: StreamThumbnailFormat.jpeg,
+ maxWidth: 128, // 0 keeps the source resolution
+ quality: 25,
+);
+```
+
+### File
+
+Write the thumbnail to disk and get back an `XFile`. If `thumbnailPath` is omitted, the
+image is saved next to the video (or in the cache directory for remote videos):
+
+```dart
+final thumbnail = await StreamThumbnail.thumbnailFile(
+ video: '/path/to/video.mp4',
+ imageFormat: StreamThumbnailFormat.png,
+ maxHeight: 200,
+);
+```
+
+### Multiple files
+
+Generate thumbnails for several videos in one call:
+
+```dart
+final thumbnails = await StreamThumbnail.thumbnailFiles(
+ videos: ['/path/to/a.mp4', '/path/to/b.mp4'],
+);
+```
+
+## Options
+
+Every method accepts the same options:
+
+| Option | Description |
+| --------------- | ------------------------------------------------------------------------------- |
+| `video`(s) | Path to a local video file or a video URL. |
+| `headers` | HTTP headers sent when fetching a remote video. |
+| `thumbnailPath` | Output path (file variants only). Defaults to the video's folder or cache dir. |
+| `imageFormat` | `JPEG`, `PNG`, or `WEBP`. Defaults to `PNG`. WebP on iOS is backed by `libwebp`.|
+| `maxHeight` / `maxWidth` | Max size in pixels, or `0` to keep the source resolution. |
+| `timeMs` | Capture position in milliseconds. |
+| `quality` | Output quality, `0`–`100` (ignored for PNG). |
diff --git a/packages/stream_thumbnail/android/build.gradle.kts b/packages/stream_thumbnail/android/build.gradle.kts
new file mode 100644
index 00000000..2c1e4722
--- /dev/null
+++ b/packages/stream_thumbnail/android/build.gradle.kts
@@ -0,0 +1,56 @@
+buildscript {
+ val kotlinVersion = "2.2.20"
+ repositories {
+ google()
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath("com.android.tools.build:gradle:8.11.1")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
+ }
+}
+
+plugins {
+ id("com.android.library")
+}
+
+group = "io.getstream.stream_thumbnail"
+version = "1.0-SNAPSHOT"
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+android {
+ namespace = "io.getstream.stream_thumbnail"
+ compileSdk = 36
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_11
+ targetCompatibility = JavaVersion.VERSION_11
+ }
+
+ sourceSets {
+ getByName("main") {
+ java.srcDirs("src/main/kotlin")
+ }
+ }
+
+ defaultConfig {
+ minSdk = 24
+ }
+
+ lint {
+ disable.add("InvalidPackage")
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_11
+ }
+}
diff --git a/packages/stream_thumbnail/android/gradle.properties b/packages/stream_thumbnail/android/gradle.properties
new file mode 100644
index 00000000..8bd86f68
--- /dev/null
+++ b/packages/stream_thumbnail/android/gradle.properties
@@ -0,0 +1 @@
+org.gradle.jvmargs=-Xmx1536M
diff --git a/packages/stream_thumbnail/android/gradle/wrapper/gradle-wrapper.properties b/packages/stream_thumbnail/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..5c82cb03
--- /dev/null
+++ b/packages/stream_thumbnail/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/packages/stream_thumbnail/android/settings.gradle.kts b/packages/stream_thumbnail/android/settings.gradle.kts
new file mode 100644
index 00000000..87b30ec8
--- /dev/null
+++ b/packages/stream_thumbnail/android/settings.gradle.kts
@@ -0,0 +1 @@
+rootProject.name = "stream_thumbnail"
diff --git a/packages/stream_thumbnail/android/src/main/AndroidManifest.xml b/packages/stream_thumbnail/android/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..c125273c
--- /dev/null
+++ b/packages/stream_thumbnail/android/src/main/AndroidManifest.xml
@@ -0,0 +1,3 @@
+
+
diff --git a/packages/stream_thumbnail/android/src/main/kotlin/io/getstream/stream_thumbnail/StreamThumbnailPlugin.kt b/packages/stream_thumbnail/android/src/main/kotlin/io/getstream/stream_thumbnail/StreamThumbnailPlugin.kt
new file mode 100644
index 00000000..4cc2306e
--- /dev/null
+++ b/packages/stream_thumbnail/android/src/main/kotlin/io/getstream/stream_thumbnail/StreamThumbnailPlugin.kt
@@ -0,0 +1,408 @@
+package io.getstream.stream_thumbnail
+
+import android.content.ContentResolver
+import android.content.Context
+import android.graphics.Bitmap
+import android.media.MediaMetadataRetriever
+import android.media.ThumbnailUtils
+import android.net.Uri
+import android.os.Build
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.util.Size
+import io.flutter.embedding.engine.plugins.FlutterPlugin
+import io.flutter.plugin.common.MethodCall
+import io.flutter.plugin.common.MethodChannel
+import io.flutter.plugin.common.MethodChannel.MethodCallHandler
+import io.flutter.plugin.common.MethodChannel.Result
+import java.io.ByteArrayOutputStream
+import java.io.File
+import java.io.FileDescriptor
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.util.LinkedList
+import java.util.concurrent.Executors
+
+/** StreamThumbnailPlugin */
+class StreamThumbnailPlugin : FlutterPlugin, MethodCallHandler {
+ /// The MethodChannel that will the communication between Flutter and native Android
+ ///
+ /// This local reference serves to register the plugin with the Flutter Engine and unregister it
+ /// when the Flutter Engine is detached from the Activity
+ private lateinit var channel: MethodChannel
+ private val TAG = "ThumbnailPlugin"
+
+ private var context: Context? = null
+ private var executor = Executors.newCachedThreadPool()
+
+ override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
+ context = flutterPluginBinding.applicationContext
+ if (executor.isShutdown) {
+ executor = Executors.newCachedThreadPool()
+ }
+ channel = MethodChannel(
+ flutterPluginBinding.binaryMessenger,
+ "plugins.getstream.io/stream_thumbnail"
+ )
+ channel.setMethodCallHandler(this)
+ }
+
+ override fun onMethodCall(call: MethodCall, result: Result) {
+ val method = call.method
+ val args = call.arguments>()
+ val callId = args?.get("callId") as Int
+
+ when (method) {
+ "files" -> {
+ result.success(true)
+ executor.execute {
+ try {
+ processFiles(args, result)
+ } catch (e: Exception) {
+ try {
+ onResult("result#error", callId, Log.getStackTraceString(e))
+ } catch (e2: Exception) {
+ onResult("result#error", callId, e2.toString())
+ }
+ }
+ }
+ }
+
+ "file" -> {
+ result.success(true)
+ executor.execute {
+ try {
+ processFile(args, result)
+ } catch (e: Exception) {
+ try {
+ onResult("result#error", callId, Log.getStackTraceString(e))
+ } catch (e2: Exception) {
+ onResult("result#error", callId, e2.toString())
+ }
+ }
+ }
+ }
+
+ "data" -> {
+ result.success(true)
+ executor.execute {
+ try {
+ processData(args, result)
+ } catch (e: Exception) {
+ try {
+ onResult("result#error", callId, Log.getStackTraceString(e))
+ } catch (e2: Exception) {
+ onResult("result#error", callId, e2.toString())
+ }
+ }
+ }
+ }
+
+ "getPlatformVersion" -> {
+ result.success("Android ${android.os.Build.VERSION.RELEASE}")
+ }
+
+ else -> result.notImplemented()
+ }
+ }
+
+ override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
+ context = null
+ channel.setMethodCallHandler(null)
+ executor.shutdown()
+ }
+
+ private fun processFiles(args: Map, result: Result) {
+ val callId = args["callId"] as Int
+ val videos: List = if (args["videos"] is List<*>) {
+ (args["videos"] as? List<*>)
+ ?.filterIsInstance()
+ ?: emptyList()
+ } else {
+ emptyList()
+ }
+ val headers: HashMap = if (args["headers"] is HashMap<*, *>) {
+ (args["headers"] as? HashMap<*, *>)
+ ?.filter { (key, value) -> key is String && value is String }
+ ?.map { (key, value) -> key as String to value as String }
+ ?.toMap(HashMap())
+ ?: HashMap()
+ } else {
+ HashMap()
+ }
+ val format = args["format"] as Int
+ val maxh = args["maxh"] as Int
+ val maxw = args["maxw"] as Int
+ val timeMs = args["timeMs"] as Int
+ val quality = args["quality"] as Int
+ val path = args["path"] as String?
+
+ val results = LinkedList()
+ for (video in videos) {
+ try {
+ if (File(video).exists()) {
+ results.add(
+ buildThumbnailFile(
+ video,
+ headers,
+ path,
+ format,
+ maxh,
+ maxw,
+ timeMs,
+ quality
+ )
+ )
+ }
+ } catch (e: IOException) {
+ continue
+ }
+ }
+ onResult("result#files", callId, results)
+ }
+
+ private fun processFile(args: Map, result: Result) {
+ val callId = args["callId"] as Int
+ val video = args["video"] as String
+ val headers: HashMap = if (args["headers"] is HashMap<*, *>) {
+ (args["headers"] as? HashMap<*, *>)
+ ?.filter { (key, value) -> key is String && value is String }
+ ?.map { (key, value) -> key as String to value as String }
+ ?.toMap(HashMap())
+ ?: HashMap()
+ } else {
+ HashMap()
+ }
+ val format = args["format"] as Int
+ val maxh = args["maxh"] as Int
+ val maxw = args["maxw"] as Int
+ val timeMs = args["timeMs"] as Int
+ val quality = args["quality"] as Int
+ val path = args["path"] as String?
+
+ val thumbnail =
+ buildThumbnailFile(video, headers, path, format, maxh, maxw, timeMs, quality)
+ onResult("result#file", callId, thumbnail)
+ }
+
+ private fun processData(args: Map, result: Result) {
+ val callId = args["callId"] as Int
+ val video = args["video"] as String
+ val headers: HashMap = if (args["headers"] is HashMap<*, *>) {
+ (args["headers"] as? HashMap<*, *>)
+ ?.filter { (key, value) -> key is String && value is String }
+ ?.map { (key, value) -> key as String to value as String }
+ ?.toMap(HashMap())
+ ?: HashMap()
+ } else {
+ HashMap()
+ }
+ val format = args["format"] as Int
+ val maxh = args["maxh"] as Int
+ val maxw = args["maxw"] as Int
+ val timeMs = args["timeMs"] as Int
+ val quality = args["quality"] as Int
+
+ val thumbnail = buildThumbnailData(video, headers, format, maxh, maxw, timeMs, quality)
+ onResult("result#data", callId, thumbnail)
+ }
+
+ private fun buildThumbnailData(
+ vidPath: String,
+ headers: HashMap,
+ format: Int,
+ maxh: Int,
+ maxw: Int,
+ timeMs: Int,
+ quality: Int
+ ): ByteArray {
+ val bitmap = createVideoThumbnail(vidPath, headers, maxh, maxw, timeMs)
+ ?: throw NullPointerException()
+ val stream = ByteArrayOutputStream()
+ bitmap.compress(intToFormat(format), quality, stream)
+ bitmap.recycle()
+ return stream.toByteArray()
+ }
+
+ private fun buildThumbnailFile(
+ vidPath: String,
+ headers: HashMap,
+ path: String?,
+ format: Int,
+ maxh: Int,
+ maxw: Int,
+ timeMs: Int,
+ quality: Int
+ ): String {
+ val bytes = buildThumbnailData(vidPath, headers, format, maxh, maxw, timeMs, quality)
+ val ext = formatExt(format)
+ val i = vidPath.lastIndexOf(".")
+ var fullpath = vidPath.substring(0, i + 1) + ext
+ val isLocalFile = vidPath.startsWith("/") || vidPath.startsWith("file://")
+
+ var savePath = path
+ if (path == null && !isLocalFile) {
+ savePath = context?.cacheDir?.absolutePath
+ }
+
+ if (savePath != null) {
+ if (savePath.endsWith(ext)) {
+ fullpath = savePath
+ } else {
+ val j = fullpath.lastIndexOf("/")
+ fullpath = if (savePath.endsWith("/")) {
+ savePath + fullpath.substring(j + 1)
+ } else {
+ savePath + fullpath.substring(j)
+ }
+ }
+ }
+
+ FileOutputStream(fullpath).use { f ->
+ f.write(bytes)
+ f.close()
+ Log.d(TAG, String.format("buildThumbnailFile( written:%d )", bytes.size))
+ }
+ return fullpath
+ }
+
+ private fun onResult(methodName: String, callId: Int, result: Any) {
+ runOnUiThread {
+ val resultMap = HashMap()
+ resultMap["callId"] = callId
+ resultMap["result"] = result
+ channel.invokeMethod(methodName, resultMap)
+ }
+ }
+
+ private fun runOnUiThread(runnable: Runnable) {
+ Handler(Looper.getMainLooper()).post(runnable)
+ }
+
+ /**
+ * Create a video thumbnail for a video. May return null if the video is corrupt
+ * or the format is not supported.
+ *
+ * @param video the URI of video
+ * @param targetH the max height of the thumbnail
+ * @param targetW the max width of the thumbnail
+ */
+ @Throws(IOException::class)
+ fun createVideoThumbnail(
+ video: String,
+ headers: HashMap,
+ targetH: Int,
+ targetW: Int,
+ timeMs: Int
+ ): Bitmap? {
+ var bitmap: Bitmap?
+ var retriever: MediaMetadataRetriever? = null
+
+ try {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && video.startsWith("/") && timeMs == -1) {
+ bitmap =
+ ThumbnailUtils.createVideoThumbnail(File(video), Size(targetW, targetH), null)
+ } else {
+ retriever = MediaMetadataRetriever()
+ if (video.startsWith("/")) {
+ setDataSource(video, retriever)
+ } else if (video.startsWith("file://")) {
+ setDataSource(video.substring(7), retriever)
+ } else if (video.startsWith("content://")) {
+ val contentResolver: ContentResolver = context!!.contentResolver
+ val assetFileDescriptor =
+ contentResolver.openAssetFileDescriptor(Uri.parse(video), "r")
+ if (assetFileDescriptor != null) {
+ val fileDescriptor: FileDescriptor = assetFileDescriptor.fileDescriptor
+ retriever.setDataSource(fileDescriptor)
+ assetFileDescriptor.close()
+ }
+ } else {
+ retriever.setDataSource(video, headers)
+ }
+
+ if (targetH != 0 || targetW != 0) {
+ if (Build.VERSION.SDK_INT >= 27 && targetH != 0 && targetW != 0) {
+ bitmap = retriever.getScaledFrameAtTime(
+ timeMs * 1000L,
+ MediaMetadataRetriever.OPTION_CLOSEST,
+ targetW,
+ targetH
+ )
+ if (bitmap == null) {
+ bitmap = retriever.getScaledFrameAtTime(
+ timeMs * 1000L,
+ MediaMetadataRetriever.OPTION_CLOSEST_SYNC,
+ targetW,
+ targetH
+ )
+ }
+ } else {
+ bitmap = retriever.getFrameAtTime(
+ timeMs * 1000L,
+ MediaMetadataRetriever.OPTION_CLOSEST
+ )
+ if (bitmap == null) {
+ bitmap = retriever.getFrameAtTime(
+ timeMs * 1000L,
+ MediaMetadataRetriever.OPTION_CLOSEST_SYNC
+ )
+ }
+ if (bitmap != null) {
+ val width = bitmap.width
+ val height = bitmap.height
+ val scaledW = targetW.takeIf { it != 0 }
+ ?: ((targetH.toFloat() / height) * width).toInt()
+ val scaledH = targetH.takeIf { it != 0 }
+ ?: ((targetW.toFloat() / width) * height).toInt()
+ bitmap = Bitmap.createScaledBitmap(bitmap, scaledW, scaledH, true)
+ }
+ }
+ } else {
+ bitmap = retriever.getFrameAtTime(
+ timeMs * 1000L,
+ MediaMetadataRetriever.OPTION_CLOSEST
+ )
+ if (bitmap == null) {
+ bitmap = retriever.getFrameAtTime(
+ timeMs * 1000L,
+ MediaMetadataRetriever.OPTION_CLOSEST_SYNC
+ )
+ }
+ }
+ }
+ } finally {
+ retriever?.release()
+ }
+
+ return bitmap
+ }
+
+ private fun setDataSource(video: String, retriever: MediaMetadataRetriever) {
+ val videoFile = File(video)
+ FileInputStream(videoFile.absolutePath).use { inputStream ->
+ retriever.setDataSource(inputStream.fd)
+ }
+ }
+
+ private fun intToFormat(format: Int): Bitmap.CompressFormat {
+ return when (format) {
+ 0 -> Bitmap.CompressFormat.JPEG
+ 1 -> Bitmap.CompressFormat.PNG
+ 2 -> Bitmap.CompressFormat.WEBP
+ else -> Bitmap.CompressFormat.JPEG
+ }
+ }
+
+ private fun formatExt(format: Int): String {
+ return when (format) {
+ 0 -> "jpg"
+ 1 -> "png"
+ 2 -> "webp"
+ else -> "jpg"
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/packages/stream_thumbnail/example/.metadata b/packages/stream_thumbnail/example/.metadata
new file mode 100644
index 00000000..3ddd7104
--- /dev/null
+++ b/packages/stream_thumbnail/example/.metadata
@@ -0,0 +1,36 @@
+# This file tracks properties of this Flutter project.
+# Used by Flutter tool to assess capabilities and perform upgrades etc.
+#
+# This file should be version controlled and should not be manually edited.
+
+version:
+ revision: "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
+ channel: "stable"
+
+project_type: app
+
+# Tracks metadata for the flutter migrate command
+migration:
+ platforms:
+ - platform: root
+ create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+ base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+ - platform: android
+ create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+ base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+ - platform: ios
+ create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+ base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+ - platform: web
+ create_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+ base_revision: 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
+
+ # User provided section
+
+ # List of Local paths (relative to this file) that should be
+ # ignored by the migrate tool.
+ #
+ # Files that are not part of the templates will be ignored by default.
+ unmanaged_files:
+ - 'lib/main.dart'
+ - 'ios/Runner.xcodeproj/project.pbxproj'
diff --git a/packages/stream_thumbnail/example/README.md b/packages/stream_thumbnail/example/README.md
new file mode 100644
index 00000000..800dfe2f
--- /dev/null
+++ b/packages/stream_thumbnail/example/README.md
@@ -0,0 +1,17 @@
+# stream_thumbnail_example
+
+A new Flutter project.
+
+## Getting Started
+
+This project is a starting point for a Flutter application.
+
+A few resources to get you started if this is your first Flutter project:
+
+- [Learn Flutter](https://docs.flutter.dev/get-started/learn-flutter)
+- [Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
+- [Flutter learning resources](https://docs.flutter.dev/reference/learning-resources)
+
+For help getting started with Flutter development, view the
+[online documentation](https://docs.flutter.dev/), which offers tutorials,
+samples, guidance on mobile development, and a full API reference.
diff --git a/packages/stream_thumbnail/example/android/app/build.gradle.kts b/packages/stream_thumbnail/example/android/app/build.gradle.kts
new file mode 100644
index 00000000..c055e830
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/app/build.gradle.kts
@@ -0,0 +1,45 @@
+plugins {
+ id("com.android.application")
+ // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
+ id("dev.flutter.flutter-gradle-plugin")
+}
+
+android {
+ namespace = "io.getstream.stream_thumbnail_example"
+ compileSdk = flutter.compileSdkVersion
+ ndkVersion = flutter.ndkVersion
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+ }
+
+ defaultConfig {
+ // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
+ applicationId = "io.getstream.stream_thumbnail_example"
+ // You can update the following values to match your application needs.
+ // For more information, see: https://flutter.dev/to/review-gradle-config.
+ minSdk = flutter.minSdkVersion
+ targetSdk = flutter.targetSdkVersion
+ versionCode = flutter.versionCode
+ versionName = flutter.versionName
+ }
+
+ buildTypes {
+ release {
+ // TODO: Add your own signing config for the release build.
+ // Signing with the debug keys for now, so `flutter run --release` works.
+ signingConfig = signingConfigs.getByName("debug")
+ }
+ }
+}
+
+kotlin {
+ compilerOptions {
+ jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
+ }
+}
+
+flutter {
+ source = "../.."
+}
diff --git a/packages/stream_thumbnail/example/android/app/src/debug/AndroidManifest.xml b/packages/stream_thumbnail/example/android/app/src/debug/AndroidManifest.xml
new file mode 100644
index 00000000..399f6981
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/app/src/debug/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/packages/stream_thumbnail/example/android/app/src/main/AndroidManifest.xml b/packages/stream_thumbnail/example/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 00000000..34858b08
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,47 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/android/app/src/main/kotlin/io/getstream/stream_thumbnail_example/MainActivity.kt b/packages/stream_thumbnail/example/android/app/src/main/kotlin/io/getstream/stream_thumbnail_example/MainActivity.kt
new file mode 100644
index 00000000..cb8ebf4e
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/app/src/main/kotlin/io/getstream/stream_thumbnail_example/MainActivity.kt
@@ -0,0 +1,5 @@
+package io.getstream.stream_thumbnail_example
+
+import io.flutter.embedding.android.FlutterActivity
+
+class MainActivity : FlutterActivity()
diff --git a/packages/stream_thumbnail/example/android/app/src/main/res/drawable-v21/launch_background.xml b/packages/stream_thumbnail/example/android/app/src/main/res/drawable-v21/launch_background.xml
new file mode 100644
index 00000000..f74085f3
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/app/src/main/res/drawable-v21/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/android/app/src/main/res/drawable/launch_background.xml b/packages/stream_thumbnail/example/android/app/src/main/res/drawable/launch_background.xml
new file mode 100644
index 00000000..304732f8
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/app/src/main/res/drawable/launch_background.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png
new file mode 100644
index 00000000..db77bb4b
Binary files /dev/null and b/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ
diff --git a/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png
new file mode 100644
index 00000000..17987b79
Binary files /dev/null and b/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-mdpi/ic_launcher.png differ
diff --git a/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
new file mode 100644
index 00000000..09d43914
Binary files /dev/null and b/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ
diff --git a/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
new file mode 100644
index 00000000..d5f1c8d3
Binary files /dev/null and b/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ
diff --git a/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
new file mode 100644
index 00000000..4d6372ee
Binary files /dev/null and b/packages/stream_thumbnail/example/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ
diff --git a/packages/stream_thumbnail/example/android/app/src/main/res/values-night/styles.xml b/packages/stream_thumbnail/example/android/app/src/main/res/values-night/styles.xml
new file mode 100644
index 00000000..06952be7
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/android/app/src/main/res/values/styles.xml b/packages/stream_thumbnail/example/android/app/src/main/res/values/styles.xml
new file mode 100644
index 00000000..cb1ef880
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/app/src/main/res/values/styles.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/android/app/src/profile/AndroidManifest.xml b/packages/stream_thumbnail/example/android/app/src/profile/AndroidManifest.xml
new file mode 100644
index 00000000..399f6981
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/app/src/profile/AndroidManifest.xml
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/packages/stream_thumbnail/example/android/build.gradle.kts b/packages/stream_thumbnail/example/android/build.gradle.kts
new file mode 100644
index 00000000..dbee657b
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/build.gradle.kts
@@ -0,0 +1,24 @@
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+val newBuildDir: Directory =
+ rootProject.layout.buildDirectory
+ .dir("../../build")
+ .get()
+rootProject.layout.buildDirectory.value(newBuildDir)
+
+subprojects {
+ val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
+ project.layout.buildDirectory.value(newSubprojectBuildDir)
+}
+subprojects {
+ project.evaluationDependsOn(":app")
+}
+
+tasks.register("clean") {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/packages/stream_thumbnail/example/android/gradle.properties b/packages/stream_thumbnail/example/android/gradle.properties
new file mode 100644
index 00000000..e96108cf
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/gradle.properties
@@ -0,0 +1,6 @@
+org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
+android.useAndroidX=true
+# This newDsl flag was added by the Flutter template
+android.newDsl=false
+# This builtInKotlin flag was added by the Flutter template
+android.builtInKotlin=false
diff --git a/packages/stream_thumbnail/example/android/gradle/wrapper/gradle-wrapper.properties b/packages/stream_thumbnail/example/android/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 00000000..2d428bfb
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-all.zip
diff --git a/packages/stream_thumbnail/example/android/settings.gradle.kts b/packages/stream_thumbnail/example/android/settings.gradle.kts
new file mode 100644
index 00000000..c21f0c5b
--- /dev/null
+++ b/packages/stream_thumbnail/example/android/settings.gradle.kts
@@ -0,0 +1,26 @@
+pluginManagement {
+ val flutterSdkPath =
+ run {
+ val properties = java.util.Properties()
+ file("local.properties").inputStream().use { properties.load(it) }
+ val flutterSdkPath = properties.getProperty("flutter.sdk")
+ require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
+ flutterSdkPath
+ }
+
+ includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
+
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+
+plugins {
+ id("dev.flutter.flutter-plugin-loader") version "1.0.0"
+ id("com.android.application") version "9.0.1" apply false
+ id("org.jetbrains.kotlin.android") version "2.3.20" apply false
+}
+
+include(":app")
diff --git a/packages/stream_thumbnail/example/ios/Flutter/AppFrameworkInfo.plist b/packages/stream_thumbnail/example/ios/Flutter/AppFrameworkInfo.plist
new file mode 100644
index 00000000..391a902b
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Flutter/AppFrameworkInfo.plist
@@ -0,0 +1,24 @@
+
+
+
+
+ CFBundleDevelopmentRegion
+ en
+ CFBundleExecutable
+ App
+ CFBundleIdentifier
+ io.flutter.flutter.app
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ App
+ CFBundlePackageType
+ FMWK
+ CFBundleShortVersionString
+ 1.0
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ 1.0
+
+
diff --git a/packages/stream_thumbnail/example/ios/Flutter/Debug.xcconfig b/packages/stream_thumbnail/example/ios/Flutter/Debug.xcconfig
new file mode 100644
index 00000000..592ceee8
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Flutter/Debug.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/packages/stream_thumbnail/example/ios/Flutter/Release.xcconfig b/packages/stream_thumbnail/example/ios/Flutter/Release.xcconfig
new file mode 100644
index 00000000..592ceee8
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Flutter/Release.xcconfig
@@ -0,0 +1 @@
+#include "Generated.xcconfig"
diff --git a/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.pbxproj b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.pbxproj
new file mode 100644
index 00000000..490cc977
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.pbxproj
@@ -0,0 +1,651 @@
+// !$*UTF8*$!
+{
+ archiveVersion = 1;
+ classes = {
+ };
+ objectVersion = 54;
+ objects = {
+
+/* Begin PBXBuildFile section */
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+ 331C8085294A63A400263BE5 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 97C146E61CF9000F007C117D /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 97C146ED1CF9000F007C117D;
+ remoteInfo = Runner;
+ };
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXCopyFilesBuildPhase section */
+ 9705A1C41CF9048500538489 /* Embed Frameworks */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 10;
+ files = (
+ );
+ name = "Embed Frameworks";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
+/* Begin PBXFileReference section */
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
+ 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; };
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; };
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; };
+ 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
+ 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; };
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; };
+ 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; };
+ 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
+ 784666492D4C4C64000A1A5F /* FlutterFramework */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterFramework; path = Flutter/ephemeral/Packages/.packages/FlutterFramework; sourceTree = ""; };
+ 78DABEA22ED26510000E7860 /* stream_thumbnail */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = stream_thumbnail; path = ../../ios/stream_thumbnail; sourceTree = ""; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+ 97C146EB1CF9000F007C117D /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+ 331C8082294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXGroup;
+ children = (
+ 331C807B294A618700263BE5 /* RunnerTests.swift */,
+ );
+ path = RunnerTests;
+ sourceTree = "";
+ };
+ 9740EEB11CF90186004384FC /* Flutter */ = {
+ isa = PBXGroup;
+ children = (
+ 78DABEA22ED26510000E7860 /* stream_thumbnail */,
+ 784666492D4C4C64000A1A5F /* FlutterFramework */,
+ 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */,
+ 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */,
+ 9740EEB21CF90195004384FC /* Debug.xcconfig */,
+ 7AFA3C8E1D35360C0083082E /* Release.xcconfig */,
+ 9740EEB31CF90195004384FC /* Generated.xcconfig */,
+ );
+ name = Flutter;
+ sourceTree = "";
+ };
+ 97C146E51CF9000F007C117D = {
+ isa = PBXGroup;
+ children = (
+ 9740EEB11CF90186004384FC /* Flutter */,
+ 97C146F01CF9000F007C117D /* Runner */,
+ 97C146EF1CF9000F007C117D /* Products */,
+ 331C8082294A63A400263BE5 /* RunnerTests */,
+ );
+ sourceTree = "";
+ };
+ 97C146EF1CF9000F007C117D /* Products */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146EE1CF9000F007C117D /* Runner.app */,
+ 331C8081294A63A400263BE5 /* RunnerTests.xctest */,
+ );
+ name = Products;
+ sourceTree = "";
+ };
+ 97C146F01CF9000F007C117D /* Runner */ = {
+ isa = PBXGroup;
+ children = (
+ 97C146FA1CF9000F007C117D /* Main.storyboard */,
+ 97C146FD1CF9000F007C117D /* Assets.xcassets */,
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */,
+ 97C147021CF9000F007C117D /* Info.plist */,
+ 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
+ 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
+ 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
+ 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */,
+ 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
+ );
+ path = Runner;
+ sourceTree = "";
+ };
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+ 331C8080294A63A400263BE5 /* RunnerTests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
+ buildPhases = (
+ 331C807D294A63A400263BE5 /* Sources */,
+ 331C807F294A63A400263BE5 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */,
+ );
+ name = RunnerTests;
+ productName = RunnerTests;
+ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */;
+ productType = "com.apple.product-type.bundle.unit-test";
+ };
+ 97C146ED1CF9000F007C117D /* Runner */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
+ buildPhases = (
+ 9740EEB61CF901F6004384FC /* Run Script */,
+ 97C146EA1CF9000F007C117D /* Sources */,
+ 97C146EB1CF9000F007C117D /* Frameworks */,
+ 97C146EC1CF9000F007C117D /* Resources */,
+ 9705A1C41CF9048500538489 /* Embed Frameworks */,
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ name = Runner;
+ packageProductDependencies = (
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */,
+ );
+ productName = Runner;
+ productReference = 97C146EE1CF9000F007C117D /* Runner.app */;
+ productType = "com.apple.product-type.application";
+ };
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+ 97C146E61CF9000F007C117D /* Project object */ = {
+ isa = PBXProject;
+ attributes = {
+ BuildIndependentTargetsInParallel = YES;
+ LastUpgradeCheck = 1510;
+ ORGANIZATIONNAME = "";
+ TargetAttributes = {
+ 331C8080294A63A400263BE5 = {
+ CreatedOnToolsVersion = 14.0;
+ TestTargetID = 97C146ED1CF9000F007C117D;
+ };
+ 97C146ED1CF9000F007C117D = {
+ CreatedOnToolsVersion = 7.3.1;
+ LastSwiftMigration = 1100;
+ };
+ };
+ };
+ buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */;
+ compatibilityVersion = "Xcode 9.3";
+ developmentRegion = en;
+ hasScannedForEncodings = 0;
+ knownRegions = (
+ en,
+ Base,
+ );
+ mainGroup = 97C146E51CF9000F007C117D;
+ packageReferences = (
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
+ );
+ productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
+ projectDirPath = "";
+ projectRoot = "";
+ targets = (
+ 97C146ED1CF9000F007C117D /* Runner */,
+ 331C8080294A63A400263BE5 /* RunnerTests */,
+ );
+ };
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+ 331C807F294A63A400263BE5 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EC1CF9000F007C117D /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */,
+ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */,
+ 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */,
+ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+ 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}",
+ );
+ name = "Thin Binary";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
+ };
+ 9740EEB61CF901F6004384FC /* Run Script */ = {
+ isa = PBXShellScriptBuildPhase;
+ alwaysOutOfDate = 1;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ inputPaths = (
+ );
+ name = "Run Script";
+ outputPaths = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ shellPath = /bin/sh;
+ shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
+ };
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+ 331C807D294A63A400263BE5 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 97C146EA1CF9000F007C117D /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
+ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
+ 7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+ 331C8086294A63A400263BE5 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 97C146ED1CF9000F007C117D /* Runner */;
+ targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */;
+ };
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+ 97C146FA1CF9000F007C117D /* Main.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C146FB1CF9000F007C117D /* Base */,
+ );
+ name = Main.storyboard;
+ sourceTree = "";
+ };
+ 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = {
+ isa = PBXVariantGroup;
+ children = (
+ 97C147001CF9000F007C117D /* Base */,
+ );
+ name = LaunchScreen.storyboard;
+ sourceTree = "";
+ };
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+ 249021D3217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Profile;
+ };
+ 249021D4217E4FDB00AE95B9 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = EHV7XZLAHA;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = io.getstream.streamThumbnailExample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Profile;
+ };
+ 331C8088294A63A400263BE5 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.getstream.streamThumbnailExample.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Debug;
+ };
+ 331C8089294A63A400263BE5 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.getstream.streamThumbnailExample.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Release;
+ };
+ 331C808A294A63A400263BE5 /* Profile */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ BUNDLE_LOADER = "$(TEST_HOST)";
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ GENERATE_INFOPLIST_FILE = YES;
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = io.getstream.streamThumbnailExample.RunnerTests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_VERSION = 5.0;
+ TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner";
+ };
+ name = Profile;
+ };
+ 97C147031CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = dwarf;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_TESTABILITY = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_DYNAMIC_NO_PIC = NO;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_OPTIMIZATION_LEVEL = 0;
+ GCC_PREPROCESSOR_DEFINITIONS = (
+ "DEBUG=1",
+ "$(inherited)",
+ );
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = YES;
+ ONLY_ACTIVE_ARCH = YES;
+ SDKROOT = iphoneos;
+ TARGETED_DEVICE_FAMILY = "1,2";
+ };
+ name = Debug;
+ };
+ 97C147041CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ ALWAYS_SEARCH_USER_PATHS = NO;
+ ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
+ CLANG_ANALYZER_NONNULL = YES;
+ CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+ CLANG_CXX_LIBRARY = "libc++";
+ CLANG_ENABLE_MODULES = YES;
+ CLANG_ENABLE_OBJC_ARC = YES;
+ CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
+ CLANG_WARN_BOOL_CONVERSION = YES;
+ CLANG_WARN_COMMA = YES;
+ CLANG_WARN_CONSTANT_CONVERSION = YES;
+ CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
+ CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+ CLANG_WARN_EMPTY_BODY = YES;
+ CLANG_WARN_ENUM_CONVERSION = YES;
+ CLANG_WARN_INFINITE_RECURSION = YES;
+ CLANG_WARN_INT_CONVERSION = YES;
+ CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
+ CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
+ CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
+ CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+ CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
+ CLANG_WARN_STRICT_PROTOTYPES = YES;
+ CLANG_WARN_SUSPICIOUS_MOVE = YES;
+ CLANG_WARN_UNREACHABLE_CODE = YES;
+ CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+ COPY_PHASE_STRIP = NO;
+ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+ ENABLE_NS_ASSERTIONS = NO;
+ ENABLE_STRICT_OBJC_MSGSEND = YES;
+ ENABLE_USER_SCRIPT_SANDBOXING = NO;
+ GCC_C_LANGUAGE_STANDARD = gnu99;
+ GCC_NO_COMMON_BLOCKS = YES;
+ GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+ GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+ GCC_WARN_UNDECLARED_SELECTOR = YES;
+ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+ GCC_WARN_UNUSED_FUNCTION = YES;
+ GCC_WARN_UNUSED_VARIABLE = YES;
+ IPHONEOS_DEPLOYMENT_TARGET = 13.0;
+ MTL_ENABLE_DEBUG_INFO = NO;
+ SDKROOT = iphoneos;
+ SUPPORTED_PLATFORMS = iphoneos;
+ SWIFT_COMPILATION_MODE = wholemodule;
+ SWIFT_OPTIMIZATION_LEVEL = "-O";
+ TARGETED_DEVICE_FAMILY = "1,2";
+ VALIDATE_PRODUCT = YES;
+ };
+ name = Release;
+ };
+ 97C147061CF9000F007C117D /* Debug */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = EHV7XZLAHA;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = io.getstream.streamThumbnailExample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Debug;
+ };
+ 97C147071CF9000F007C117D /* Release */ = {
+ isa = XCBuildConfiguration;
+ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */;
+ buildSettings = {
+ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+ CLANG_ENABLE_MODULES = YES;
+ CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
+ DEVELOPMENT_TEAM = EHV7XZLAHA;
+ ENABLE_BITCODE = NO;
+ INFOPLIST_FILE = Runner/Info.plist;
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/Frameworks",
+ );
+ PRODUCT_BUNDLE_IDENTIFIER = io.getstream.streamThumbnailExample;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
+ SWIFT_VERSION = 5.0;
+ VERSIONING_SYSTEM = "apple-generic";
+ };
+ name = Release;
+ };
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+ 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 331C8088294A63A400263BE5 /* Debug */,
+ 331C8089294A63A400263BE5 /* Release */,
+ 331C808A294A63A400263BE5 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147031CF9000F007C117D /* Debug */,
+ 97C147041CF9000F007C117D /* Release */,
+ 249021D3217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 97C147061CF9000F007C117D /* Debug */,
+ 97C147071CF9000F007C117D /* Release */,
+ 249021D4217E4FDB00AE95B9 /* Profile */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+/* End XCConfigurationList section */
+
+/* Begin XCLocalSwiftPackageReference section */
+ 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
+ isa = XCLocalSwiftPackageReference;
+ relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCLocalSwiftPackageReference section */
+
+/* Begin XCSwiftPackageProductDependency section */
+ 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
+ isa = XCSwiftPackageProductDependency;
+ productName = FlutterGeneratedPluginSwiftPackage;
+ };
+/* End XCSwiftPackageProductDependency section */
+ };
+ rootObject = 97C146E61CF9000F007C117D /* Project object */;
+}
diff --git a/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..919434a6
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 00000000..18d98100
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 00000000..f9b0d7c5
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 00000000..ff75c864
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "pins" : [
+ {
+ "identity" : "libwebp-xcode",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/SDWebImage/libwebp-Xcode.git",
+ "state" : {
+ "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2",
+ "version" : "1.5.0"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/packages/stream_thumbnail/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
new file mode 100644
index 00000000..c3fedb29
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme
@@ -0,0 +1,119 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/ios/Runner.xcworkspace/contents.xcworkspacedata b/packages/stream_thumbnail/example/ios/Runner.xcworkspace/contents.xcworkspacedata
new file mode 100644
index 00000000..1d526a16
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner.xcworkspace/contents.xcworkspacedata
@@ -0,0 +1,7 @@
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/packages/stream_thumbnail/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
new file mode 100644
index 00000000..18d98100
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist
@@ -0,0 +1,8 @@
+
+
+
+
+ IDEDidComputeMac32BitWarning
+
+
+
diff --git a/packages/stream_thumbnail/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/packages/stream_thumbnail/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
new file mode 100644
index 00000000..f9b0d7c5
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings
@@ -0,0 +1,8 @@
+
+
+
+
+ PreviewsEnabled
+
+
+
diff --git a/packages/stream_thumbnail/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/packages/stream_thumbnail/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
new file mode 100644
index 00000000..ff75c864
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -0,0 +1,14 @@
+{
+ "pins" : [
+ {
+ "identity" : "libwebp-xcode",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/SDWebImage/libwebp-Xcode.git",
+ "state" : {
+ "revision" : "0d60654eeefd5d7d2bef3835804892c40225e8b2",
+ "version" : "1.5.0"
+ }
+ }
+ ],
+ "version" : 2
+}
diff --git a/packages/stream_thumbnail/example/ios/Runner/AppDelegate.swift b/packages/stream_thumbnail/example/ios/Runner/AppDelegate.swift
new file mode 100644
index 00000000..c30b367e
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner/AppDelegate.swift
@@ -0,0 +1,16 @@
+import Flutter
+import UIKit
+
+@main
+@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
+ override func application(
+ _ application: UIApplication,
+ didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
+ ) -> Bool {
+ return super.application(application, didFinishLaunchingWithOptions: launchOptions)
+ }
+
+ func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
+ GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
+ }
+}
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
new file mode 100644
index 00000000..d36b1fab
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -0,0 +1,122 @@
+{
+ "images" : [
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-20x20@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-29x29@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-40x40@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "60x60",
+ "idiom" : "iphone",
+ "filename" : "Icon-App-60x60@3x.png",
+ "scale" : "3x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "20x20",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-20x20@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "29x29",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-29x29@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "40x40",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-40x40@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@1x.png",
+ "scale" : "1x"
+ },
+ {
+ "size" : "76x76",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-76x76@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "83.5x83.5",
+ "idiom" : "ipad",
+ "filename" : "Icon-App-83.5x83.5@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "size" : "1024x1024",
+ "idiom" : "ios-marketing",
+ "filename" : "Icon-App-1024x1024@1x.png",
+ "scale" : "1x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png
new file mode 100644
index 00000000..dc9ada47
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png
new file mode 100644
index 00000000..7353c41e
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png
new file mode 100644
index 00000000..797d452e
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png
new file mode 100644
index 00000000..6ed2d933
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png
new file mode 100644
index 00000000..4cd7b009
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png
new file mode 100644
index 00000000..fe730945
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png
new file mode 100644
index 00000000..321773cd
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png
new file mode 100644
index 00000000..797d452e
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png
new file mode 100644
index 00000000..502f463a
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png
new file mode 100644
index 00000000..0ec30343
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png
new file mode 100644
index 00000000..0ec30343
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png
new file mode 100644
index 00000000..e9f5fea2
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png
new file mode 100644
index 00000000..84ac32ae
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png
new file mode 100644
index 00000000..8953cba0
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png
new file mode 100644
index 00000000..0467bf12
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
new file mode 100644
index 00000000..0bedcf2f
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json
@@ -0,0 +1,23 @@
+{
+ "images" : [
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage.png",
+ "scale" : "1x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@2x.png",
+ "scale" : "2x"
+ },
+ {
+ "idiom" : "universal",
+ "filename" : "LaunchImage@3x.png",
+ "scale" : "3x"
+ }
+ ],
+ "info" : {
+ "version" : 1,
+ "author" : "xcode"
+ }
+}
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png
new file mode 100644
index 00000000..9da19eac
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png
new file mode 100644
index 00000000..9da19eac
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@2x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png
new file mode 100644
index 00000000..9da19eac
Binary files /dev/null and b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/LaunchImage@3x.png differ
diff --git a/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
new file mode 100644
index 00000000..89c2725b
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner/Assets.xcassets/LaunchImage.imageset/README.md
@@ -0,0 +1,5 @@
+# Launch Screen Assets
+
+You can customize the launch screen with your own desired assets by replacing the image files in this directory.
+
+You can also do it by opening your Flutter project's Xcode project with `open ios/Runner.xcworkspace`, selecting `Runner/Assets.xcassets` in the Project Navigator and dropping in the desired images.
\ No newline at end of file
diff --git a/packages/stream_thumbnail/example/ios/Runner/Base.lproj/LaunchScreen.storyboard b/packages/stream_thumbnail/example/ios/Runner/Base.lproj/LaunchScreen.storyboard
new file mode 100644
index 00000000..f2e259c7
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner/Base.lproj/LaunchScreen.storyboard
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/ios/Runner/Base.lproj/Main.storyboard b/packages/stream_thumbnail/example/ios/Runner/Base.lproj/Main.storyboard
new file mode 100644
index 00000000..f3c28516
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner/Base.lproj/Main.storyboard
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/ios/Runner/Info.plist b/packages/stream_thumbnail/example/ios/Runner/Info.plist
new file mode 100644
index 00000000..9accdef1
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner/Info.plist
@@ -0,0 +1,70 @@
+
+
+
+
+ CADisableMinimumFrameDurationOnPhone
+
+ CFBundleDevelopmentRegion
+ $(DEVELOPMENT_LANGUAGE)
+ CFBundleDisplayName
+ Stream Video Thumbnail Example
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleInfoDictionaryVersion
+ 6.0
+ CFBundleName
+ stream_thumbnail_example
+ CFBundlePackageType
+ APPL
+ CFBundleShortVersionString
+ $(FLUTTER_BUILD_NAME)
+ CFBundleSignature
+ ????
+ CFBundleVersion
+ $(FLUTTER_BUILD_NUMBER)
+ LSRequiresIPhoneOS
+
+ UIApplicationSceneManifest
+
+ UIApplicationSupportsMultipleScenes
+
+ UISceneConfigurations
+
+ UIWindowSceneSessionRoleApplication
+
+
+ UISceneClassName
+ UIWindowScene
+ UISceneConfigurationName
+ flutter
+ UISceneDelegateClassName
+ $(PRODUCT_MODULE_NAME).SceneDelegate
+ UISceneStoryboardFile
+ Main
+
+
+
+
+ UIApplicationSupportsIndirectInputEvents
+
+ UILaunchStoryboardName
+ LaunchScreen
+ UIMainStoryboardFile
+ Main
+ UISupportedInterfaceOrientations
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+ UISupportedInterfaceOrientations~ipad
+
+ UIInterfaceOrientationPortrait
+ UIInterfaceOrientationPortraitUpsideDown
+ UIInterfaceOrientationLandscapeLeft
+ UIInterfaceOrientationLandscapeRight
+
+
+
diff --git a/packages/stream_thumbnail/example/ios/Runner/Runner-Bridging-Header.h b/packages/stream_thumbnail/example/ios/Runner/Runner-Bridging-Header.h
new file mode 100644
index 00000000..308a2a56
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner/Runner-Bridging-Header.h
@@ -0,0 +1 @@
+#import "GeneratedPluginRegistrant.h"
diff --git a/packages/stream_thumbnail/example/ios/Runner/SceneDelegate.swift b/packages/stream_thumbnail/example/ios/Runner/SceneDelegate.swift
new file mode 100644
index 00000000..b9ce8ea2
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/Runner/SceneDelegate.swift
@@ -0,0 +1,6 @@
+import Flutter
+import UIKit
+
+class SceneDelegate: FlutterSceneDelegate {
+
+}
diff --git a/packages/stream_thumbnail/example/ios/RunnerTests/RunnerTests.swift b/packages/stream_thumbnail/example/ios/RunnerTests/RunnerTests.swift
new file mode 100644
index 00000000..86a7c3b1
--- /dev/null
+++ b/packages/stream_thumbnail/example/ios/RunnerTests/RunnerTests.swift
@@ -0,0 +1,12 @@
+import Flutter
+import UIKit
+import XCTest
+
+class RunnerTests: XCTestCase {
+
+ func testExample() {
+ // If you add code to the Runner application, consider adding tests here.
+ // See https://developer.apple.com/documentation/xctest for more information about using XCTest.
+ }
+
+}
diff --git a/packages/stream_thumbnail/example/lib/main.dart b/packages/stream_thumbnail/example/lib/main.dart
new file mode 100644
index 00000000..dae19066
--- /dev/null
+++ b/packages/stream_thumbnail/example/lib/main.dart
@@ -0,0 +1,97 @@
+import 'dart:typed_data';
+
+import 'package:flutter/material.dart';
+import 'package:stream_thumbnail/stream_thumbnail.dart';
+
+void main() => runApp(const ExampleApp());
+
+class ExampleApp extends StatelessWidget {
+ const ExampleApp({super.key});
+
+ @override
+ Widget build(BuildContext context) {
+ return const MaterialApp(
+ title: 'stream_thumbnail example',
+ home: ThumbnailPage(),
+ );
+ }
+}
+
+class ThumbnailPage extends StatefulWidget {
+ const ThumbnailPage({super.key});
+
+ @override
+ State createState() => _ThumbnailPageState();
+}
+
+class _ThumbnailPageState extends State {
+ static const _sampleVideo = 'https://flutter.github.io/assets-for-api-docs/assets/videos/bee.mp4';
+
+ final _controller = TextEditingController(text: _sampleVideo);
+ Future? _thumbnail;
+
+ @override
+ void dispose() {
+ _controller.dispose();
+ super.dispose();
+ }
+
+ void _generate() {
+ setState(() {
+ _thumbnail = StreamThumbnail.thumbnailData(
+ video: _controller.text,
+ imageFormat: StreamThumbnailFormat.jpeg,
+ maxWidth: 300,
+ quality: 75,
+ );
+ });
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Scaffold(
+ appBar: AppBar(title: const Text('Video Thumbnail')),
+ body: Padding(
+ padding: const EdgeInsets.all(16),
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ TextField(
+ controller: _controller,
+ decoration: const InputDecoration(
+ labelText: 'Video file path or URL',
+ ),
+ ),
+ const SizedBox(height: 16),
+ FilledButton(
+ onPressed: _generate,
+ child: const Text('Generate thumbnail'),
+ ),
+ const SizedBox(height: 24),
+ Expanded(child: Center(child: _buildPreview())),
+ ],
+ ),
+ ),
+ );
+ }
+
+ Widget _buildPreview() {
+ final thumbnail = _thumbnail;
+ if (thumbnail == null) {
+ return const Text('Tap "Generate thumbnail" to preview a frame.');
+ }
+
+ return FutureBuilder(
+ future: thumbnail,
+ builder: (context, snapshot) {
+ if (snapshot.connectionState != ConnectionState.done) {
+ return const CircularProgressIndicator();
+ }
+ if (snapshot.hasError) {
+ return Text('Failed to generate thumbnail:\n${snapshot.error}');
+ }
+ return Image.memory(snapshot.data!);
+ },
+ );
+ }
+}
diff --git a/packages/stream_thumbnail/example/pubspec.yaml b/packages/stream_thumbnail/example/pubspec.yaml
new file mode 100644
index 00000000..0e03b2b6
--- /dev/null
+++ b/packages/stream_thumbnail/example/pubspec.yaml
@@ -0,0 +1,20 @@
+name: stream_thumbnail_example
+description: "An example app for the stream_thumbnail plugin."
+publish_to: 'none'
+version: 1.0.0+1
+
+environment:
+ sdk: ^3.12.0
+
+dependencies:
+ flutter:
+ sdk: flutter
+ stream_thumbnail:
+ path: ..
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+
+flutter:
+ uses-material-design: true
diff --git a/packages/stream_thumbnail/example/web/favicon.png b/packages/stream_thumbnail/example/web/favicon.png
new file mode 100644
index 00000000..8aaa46ac
Binary files /dev/null and b/packages/stream_thumbnail/example/web/favicon.png differ
diff --git a/packages/stream_thumbnail/example/web/icons/Icon-192.png b/packages/stream_thumbnail/example/web/icons/Icon-192.png
new file mode 100644
index 00000000..b749bfef
Binary files /dev/null and b/packages/stream_thumbnail/example/web/icons/Icon-192.png differ
diff --git a/packages/stream_thumbnail/example/web/icons/Icon-512.png b/packages/stream_thumbnail/example/web/icons/Icon-512.png
new file mode 100644
index 00000000..88cfd48d
Binary files /dev/null and b/packages/stream_thumbnail/example/web/icons/Icon-512.png differ
diff --git a/packages/stream_thumbnail/example/web/icons/Icon-maskable-192.png b/packages/stream_thumbnail/example/web/icons/Icon-maskable-192.png
new file mode 100644
index 00000000..eb9b4d76
Binary files /dev/null and b/packages/stream_thumbnail/example/web/icons/Icon-maskable-192.png differ
diff --git a/packages/stream_thumbnail/example/web/icons/Icon-maskable-512.png b/packages/stream_thumbnail/example/web/icons/Icon-maskable-512.png
new file mode 100644
index 00000000..d69c5669
Binary files /dev/null and b/packages/stream_thumbnail/example/web/icons/Icon-maskable-512.png differ
diff --git a/packages/stream_thumbnail/example/web/index.html b/packages/stream_thumbnail/example/web/index.html
new file mode 100644
index 00000000..3faa05d2
--- /dev/null
+++ b/packages/stream_thumbnail/example/web/index.html
@@ -0,0 +1,46 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ stream_thumbnail_example
+
+
+
+
+
+
+
diff --git a/packages/stream_thumbnail/example/web/manifest.json b/packages/stream_thumbnail/example/web/manifest.json
new file mode 100644
index 00000000..a84c77e6
--- /dev/null
+++ b/packages/stream_thumbnail/example/web/manifest.json
@@ -0,0 +1,35 @@
+{
+ "name": "stream_thumbnail_example",
+ "short_name": "stream_thumbnail_example",
+ "start_url": ".",
+ "display": "standalone",
+ "background_color": "#0175C2",
+ "theme_color": "#0175C2",
+ "description": "A new Flutter project.",
+ "orientation": "portrait-primary",
+ "prefer_related_applications": false,
+ "icons": [
+ {
+ "src": "icons/Icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "icons/Icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ },
+ {
+ "src": "icons/Icon-maskable-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "maskable"
+ },
+ {
+ "src": "icons/Icon-maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ]
+}
diff --git a/packages/stream_thumbnail/ios/stream_thumbnail.podspec b/packages/stream_thumbnail/ios/stream_thumbnail.podspec
new file mode 100644
index 00000000..3e3f4559
--- /dev/null
+++ b/packages/stream_thumbnail/ios/stream_thumbnail.podspec
@@ -0,0 +1,24 @@
+#
+# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html
+#
+Pod::Spec.new do |s|
+ s.name = 'stream_thumbnail'
+ s.version = '0.0.1'
+ s.summary = 'A Flutter plugin for creating a thumbnail from a local video file or from a video URL.'
+ s.description = <<-DESC
+A Flutter plugin for creating a thumbnail from a local video file or from a video URL.
+ DESC
+ s.homepage = 'https://github.com/GetStream/stream-core-flutter'
+ s.license = { :file => '../LICENSE' }
+ s.author = { 'Stream' => 'support@getstream.io' }
+ s.source = { :path => '.' }
+ s.source_files = 'stream_thumbnail/Sources/stream_thumbnail/**/*.{h,m}'
+ s.public_header_files = 'stream_thumbnail/Sources/stream_thumbnail/include/**/*.h'
+ s.pod_target_xcconfig = {
+ 'USER_HEADER_SEARCH_PATHS' => '$(inherited) ${PODS_ROOT}/libwebp/**'
+ }
+ s.dependency 'Flutter'
+ s.dependency 'libwebp'
+
+ s.ios.deployment_target = '13.0'
+end
diff --git a/packages/stream_thumbnail/ios/stream_thumbnail/Package.swift b/packages/stream_thumbnail/ios/stream_thumbnail/Package.swift
new file mode 100644
index 00000000..a8705823
--- /dev/null
+++ b/packages/stream_thumbnail/ios/stream_thumbnail/Package.swift
@@ -0,0 +1,30 @@
+// swift-tools-version: 5.9
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let package = Package(
+ name: "stream_thumbnail",
+ platforms: [
+ .iOS("13.0")
+ ],
+ products: [
+ .library(name: "stream-thumbnail", targets: ["stream_thumbnail"])
+ ],
+ dependencies: [
+ .package(name: "FlutterFramework", path: "../FlutterFramework"),
+ .package(url: "https://github.com/SDWebImage/libwebp-Xcode.git", from: "1.5.0")
+ ],
+ targets: [
+ .target(
+ name: "stream_thumbnail",
+ dependencies: [
+ .product(name: "FlutterFramework", package: "FlutterFramework"),
+ .product(name: "libwebp", package: "libwebp-Xcode")
+ ],
+ cSettings: [
+ .headerSearchPath("include/stream_thumbnail")
+ ]
+ )
+ ]
+)
diff --git a/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/StreamThumbnailPlugin.m b/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/StreamThumbnailPlugin.m
new file mode 100644
index 00000000..09caa669
--- /dev/null
+++ b/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/StreamThumbnailPlugin.m
@@ -0,0 +1,190 @@
+#import "./include/stream_thumbnail/StreamThumbnailPlugin.h"
+#import
+#import
+
+#if __has_include("webp/decode.h") && __has_include("webp/encode.h") && __has_include("webp/demux.h") && __has_include("webp/mux.h")
+#import "webp/decode.h"
+#import "webp/encode.h"
+#import "webp/demux.h"
+#import "webp/mux.h"
+#elif __has_include() && __has_include() && __has_include() && __has_include()
+#import
+#import
+#import
+#import
+#endif
+
+@implementation StreamThumbnailPlugin
++ (void)registerWithRegistrar:(NSObject*)registrar {
+ FlutterMethodChannel* channel = [FlutterMethodChannel
+ methodChannelWithName:@"plugins.getstream.io/stream_thumbnail"
+ binaryMessenger:[registrar messenger]];
+ StreamThumbnailPlugin* instance = [[StreamThumbnailPlugin alloc] init];
+ [registrar addMethodCallDelegate:instance channel:channel];
+}
+
+- (void)handleMethodCall:(FlutterMethodCall*)call result:(FlutterResult)result {
+
+ NSDictionary *_args = call.arguments;
+
+ NSString *file = _args[@"video"];
+
+ NSMutableDictionary * headers = _args[@"headers"];
+
+ NSString *path = _args[@"path"];
+ int format = [[_args objectForKey:@"format"] intValue];
+ int maxh = [[_args objectForKey:@"maxh"] intValue];
+ int maxw = [[_args objectForKey:@"maxw"] intValue];
+ int timeMs = [[_args objectForKey:@"timeMs"] intValue];
+ int quality = [[_args objectForKey:@"quality"] intValue];
+ _args = nil;
+ bool isLocalFile = [file hasPrefix:@"file://"] || [file hasPrefix:@"/"];
+
+ NSURL *url = [file hasPrefix:@"file://"] ? [NSURL fileURLWithPath:[file substringFromIndex:7]] :
+ ( [file hasPrefix:@"/"] ? [NSURL fileURLWithPath:file] : [NSURL URLWithString:file] );
+
+ if ([@"data" isEqualToString:call.method]) {
+
+ dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void){
+ //Background Thread
+ NSData *data = [StreamThumbnailPlugin generateThumbnail:url headers:headers format:format maxHeight:maxh maxWidth:maxw timeMs:timeMs quality:quality];
+ //Deliver the result on the main thread, as Flutter requires.
+ dispatch_async(dispatch_get_main_queue(), ^(void){
+ result(data);
+ });
+ });
+
+ } else if ([@"file" isEqualToString:call.method]) {
+ if( [path isEqual:[NSNull null]] && !isLocalFile ) {
+ path = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) lastObject];
+ }
+
+ dispatch_async(dispatch_get_global_queue( DISPATCH_QUEUE_PRIORITY_DEFAULT, 0), ^(void){
+ //Background Thread
+
+ NSData *data = [StreamThumbnailPlugin generateThumbnail:url headers:headers format:format maxHeight:maxh maxWidth:maxw timeMs:timeMs quality:quality];
+ NSString *ext = ( (format == 0 ) ? @"jpg" : ( format == 1 ) ? @"png" : @"webp" );
+ NSURL *thumbnail = [[url URLByDeletingPathExtension] URLByAppendingPathExtension:ext];
+
+ if(path && [path isKindOfClass:[NSString class]] && path.length>0) {
+ NSString *lastPart = [thumbnail lastPathComponent];
+ thumbnail = [NSURL fileURLWithPath:path];
+ if( ![[thumbnail pathExtension] isEqualToString:ext] ) {
+ thumbnail = [thumbnail URLByAppendingPathComponent:lastPart];
+ }
+ }
+
+ NSError *error = nil;
+ id fileResult;
+ if( [data writeToURL:thumbnail options:0 error:&error] != YES ) {
+ if( error != nil ) {
+ fileResult = [FlutterError errorWithCode:[NSString stringWithFormat:@"Error %ld", error.code]
+ message:error.domain
+ details:error.localizedDescription];
+ } else {
+ fileResult = [FlutterError errorWithCode:@"IO Error" message:@"Failed to write data to file" details:nil];
+ }
+ } else {
+ NSString *fullpath = [thumbnail absoluteString];
+ fileResult = [fullpath hasPrefix:@"file://"] ? [fullpath substringFromIndex:7] : fullpath;
+ }
+ //Deliver the result on the main thread, as Flutter requires.
+ dispatch_async(dispatch_get_main_queue(), ^(void){
+ result(fileResult);
+ });
+ });
+
+ } else {
+ result(FlutterMethodNotImplemented);
+ }
+}
+
++ (NSData *)generateThumbnail:(NSURL*)url headers:(NSMutableDictionary*)headers format:(int)format maxHeight:(int)maxh maxWidth:(int)maxw timeMs:(int)timeMs quality:(int)quality {
+
+ AVURLAsset *asset = [[AVURLAsset alloc] initWithURL:url options: [headers isEqual:[NSNull null]] ? nil : @{@"AVURLAssetHTTPHeaderFieldsKey" : headers}];
+ AVAssetImageGenerator *imgGenerator = [[AVAssetImageGenerator alloc] initWithAsset:asset];
+
+ imgGenerator.appliesPreferredTrackTransform = YES;
+ imgGenerator.maximumSize = CGSizeMake((CGFloat)maxw, (CGFloat)maxh);
+ imgGenerator.requestedTimeToleranceBefore = kCMTimeZero;
+ imgGenerator.requestedTimeToleranceAfter = CMTimeMake(100, 1000);
+
+ NSError *error = nil;
+ CGImageRef cgImage = [imgGenerator copyCGImageAtTime:CMTimeMake(timeMs, 1000) actualTime:nil error:&error];
+
+ if( error != nil || cgImage == NULL ) {
+ NSLog(@"couldn't generate thumbnail, error:%@", error);
+ return nil;
+ }
+
+ if( format <= 1 ) {
+ UIImage *thumbnail = [UIImage imageWithCGImage:cgImage];
+
+ CGImageRelease(cgImage); // CGImageRef won't be released by ARC
+
+ if( format == 0 ) {
+ CGFloat fQuality = ( CGFloat) ( quality * 0.01 );
+ return UIImageJPEGRepresentation( thumbnail, fQuality );
+ } else {
+ return UIImagePNGRepresentation( thumbnail );
+ }
+ } else {
+ CGColorSpaceRef colorSpace = CGImageGetColorSpace(cgImage);
+ if (CGColorSpaceGetModel(colorSpace) != kCGColorSpaceModelRGB) {
+ CGImageRelease(cgImage);
+ return nil;
+ }
+ CGImageAlphaInfo ainfo = CGImageGetAlphaInfo( cgImage );
+ CGBitmapInfo binfo = CGImageGetBitmapInfo( cgImage );
+
+ CGDataProviderRef dataProvider = CGImageGetDataProvider(cgImage);
+ CFDataRef imageData = CGDataProviderCopyData(dataProvider);
+ UInt8 *rawData = ( UInt8 * ) CFDataGetBytePtr(imageData);
+
+ int width = ( int ) CGImageGetWidth(cgImage);
+ int height = ( int ) CGImageGetHeight(cgImage);
+ int stride = ( int ) CGImageGetBytesPerRow(cgImage);
+ size_t ret_size = 0;
+ uint8_t *output = NULL;
+
+ // preprocess the data for libwebp
+ if( ainfo == kCGImageAlphaPremultipliedFirst || ainfo == kCGImageAlphaNoneSkipFirst ) {
+ if( ( binfo & kCGBitmapByteOrderMask ) == kCGBitmapByteOrder32Little ) {
+ // Little-endian ( iPhone )
+ if( quality == 100 )
+ ret_size = WebPEncodeLosslessBGRA(rawData, width, height, stride, &output);
+ else
+ ret_size = WebPEncodeBGRA(rawData, width, height, stride, (float)quality, &output);
+ } else
+ if( ( binfo & kCGBitmapByteOrderMask ) == kCGBitmapByteOrder32Big ) {
+ // Big-endian ( iPhone Simulator )
+ for(int y = 0;y> 8 ) & 0x00FFFFFF );
+ }
+ }
+ if( quality == 100 )
+ ret_size = WebPEncodeLosslessRGBA(rawData, width, height, stride, &output);
+ else
+ ret_size = WebPEncodeRGBA(rawData, width, height, stride, (float)quality, &output);
+ }
+ }
+ else {
+ NSLog(@"Sorry, don't support this CGImageAlphaInfo: %d", (int) binfo );
+ }
+ // `colorSpace` (CGImageGetColorSpace) and `dataProvider`
+ // (CGImageGetDataProvider) are get-references and must not be released.
+ // `imageData` (CGDataProviderCopyData) and `cgImage` (copyCGImageAtTime)
+ // are owned and must be.
+ CFRelease(imageData);
+ CGImageRelease(cgImage);
+
+ NSData *data = ret_size > 0 ? [NSData dataWithBytes:(const void *)output length:ret_size] : nil;
+ WebPFree(output);
+ return data;
+ }
+}
+
+@end
diff --git a/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/include/stream_thumbnail/StreamThumbnailPlugin.h b/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/include/stream_thumbnail/StreamThumbnailPlugin.h
new file mode 100644
index 00000000..4ffd0132
--- /dev/null
+++ b/packages/stream_thumbnail/ios/stream_thumbnail/Sources/stream_thumbnail/include/stream_thumbnail/StreamThumbnailPlugin.h
@@ -0,0 +1,4 @@
+#import
+
+@interface StreamThumbnailPlugin : NSObject
+@end
diff --git a/packages/stream_thumbnail/lib/src/stream_thumbnail.dart b/packages/stream_thumbnail/lib/src/stream_thumbnail.dart
new file mode 100644
index 00000000..eed9dcb3
--- /dev/null
+++ b/packages/stream_thumbnail/lib/src/stream_thumbnail.dart
@@ -0,0 +1,103 @@
+// ignore_for_file: avoid_classes_with_only_static_members
+
+import 'dart:async';
+
+import 'package:cross_file/cross_file.dart';
+import 'package:flutter/services.dart';
+
+import 'stream_thumbnail_format.dart';
+import 'stream_thumbnail_platform.dart';
+
+/// Creates thumbnails from a local video file or from a video URL.
+abstract final class StreamThumbnail {
+ /// Generates a thumbnail file for each of the given `videos`.
+ ///
+ /// Each video can be a local file or a URL in an iOS/Android supported video
+ /// format. When `thumbnailPath` is null, files are written next to each
+ /// video. Use `maxHeight`/`maxWidth` to bound the size, or `0` to keep the
+ /// source resolution. A lower `quality` reduces image quality but is ignored
+ /// for the `PNG` format.
+ static Future> thumbnailFiles({
+ required List videos,
+ Map? headers,
+ String? thumbnailPath,
+ StreamThumbnailFormat imageFormat = StreamThumbnailFormat.png,
+ int maxHeight = 0,
+ int maxWidth = 0,
+ int? timeMs,
+ int quality = 10,
+ }) async {
+ if (videos.isEmpty) return [];
+
+ return StreamThumbnailPlatform.instance.thumbnailFiles(
+ videos: videos,
+ headers: headers,
+ thumbnailPath: thumbnailPath,
+ imageFormat: imageFormat,
+ maxHeight: maxHeight,
+ maxWidth: maxWidth,
+ timeMs: timeMs,
+ quality: quality,
+ );
+ }
+
+ /// Generates a thumbnail file for the given `video`.
+ ///
+ /// The video can be a local file or a URL in an iOS/Android supported video
+ /// format. When `thumbnailPath` is null, the file is written next to the
+ /// video. Use `maxHeight`/`maxWidth` to bound the size, or `0` to keep the
+ /// source resolution. A lower `quality` reduces image quality but is ignored
+ /// for the `PNG` format.
+ static Future thumbnailFile({
+ required String video,
+ Map? headers,
+ String? thumbnailPath,
+ StreamThumbnailFormat imageFormat = StreamThumbnailFormat.png,
+ int maxHeight = 0,
+ int maxWidth = 0,
+ int? timeMs,
+ int quality = 10,
+ }) {
+ assert(video.isNotEmpty);
+
+ return StreamThumbnailPlatform.instance.thumbnailFile(
+ video: video,
+ headers: headers,
+ thumbnailPath: thumbnailPath,
+ imageFormat: imageFormat,
+ maxHeight: maxHeight,
+ maxWidth: maxWidth,
+ timeMs: timeMs,
+ quality: quality,
+ );
+ }
+
+ /// Generates a thumbnail for the given `video` as in-memory bytes.
+ ///
+ /// The returned bytes can be rendered directly with `Image.memory`. The video
+ /// can be a local file or a URL in an iOS/Android supported video format. Use
+ /// `maxHeight`/`maxWidth` to bound the size, or `0` to keep the source
+ /// resolution. A lower `quality` reduces image quality but is ignored for the
+ /// `PNG` format.
+ static Future thumbnailData({
+ required String video,
+ Map? headers,
+ StreamThumbnailFormat imageFormat = StreamThumbnailFormat.png,
+ int maxHeight = 0,
+ int maxWidth = 0,
+ int? timeMs,
+ int quality = 10,
+ }) {
+ assert(video.isNotEmpty);
+
+ return StreamThumbnailPlatform.instance.thumbnailData(
+ video: video,
+ headers: headers,
+ imageFormat: imageFormat,
+ maxHeight: maxHeight,
+ maxWidth: maxWidth,
+ timeMs: timeMs,
+ quality: quality,
+ );
+ }
+}
diff --git a/packages/stream_thumbnail/lib/src/stream_thumbnail_format.dart b/packages/stream_thumbnail/lib/src/stream_thumbnail_format.dart
new file mode 100644
index 00000000..eeb60bdb
--- /dev/null
+++ b/packages/stream_thumbnail/lib/src/stream_thumbnail_format.dart
@@ -0,0 +1,4 @@
+/// Supported image formats for generated thumbnails.
+///
+/// Uses libwebp to encode WebP images on iOS.
+enum StreamThumbnailFormat { jpeg, png, webp }
diff --git a/packages/stream_thumbnail/lib/src/stream_thumbnail_method_channel.dart b/packages/stream_thumbnail/lib/src/stream_thumbnail_method_channel.dart
new file mode 100644
index 00000000..ed0f8f39
--- /dev/null
+++ b/packages/stream_thumbnail/lib/src/stream_thumbnail_method_channel.dart
@@ -0,0 +1,243 @@
+// The method-channel plumbing (request encoding and the native-to-Dart result
+// callbacks) is exercised end-to-end via the example app on each platform
+// rather than through unit tests.
+// coverage:ignore-file
+import 'dart:async';
+
+import 'package:cross_file/cross_file.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+
+import 'stream_thumbnail_format.dart';
+import 'stream_thumbnail_platform.dart';
+
+/// An implementation of [StreamThumbnailPlatform] that uses method
+/// channels.
+class MethodChannelStreamThumbnail extends StreamThumbnailPlatform {
+ MethodChannelStreamThumbnail() {
+ methodChannel.setMethodCallHandler(_resolveCall);
+ }
+
+ /// The method channel used to interact with the native platform.
+ static const methodChannel = MethodChannel(
+ 'plugins.getstream.io/stream_thumbnail',
+ );
+
+ final _futures = >{};
+
+ var _nextCallId = 0;
+
+ Future _resolveCall(MethodCall call) async {
+ switch (call.method) {
+ case 'result#files':
+ _resolveFilesCall(call);
+ return;
+
+ case 'result#file':
+ _resolveFileCall(call);
+ return;
+
+ case 'result#data':
+ _resolveDataCall(call);
+ return;
+
+ case 'result#error':
+ _resolveError(call);
+ return;
+
+ default:
+ throw PlatformException(
+ code: 'Unimplemented',
+ details: 'Unknown method ${call.method}',
+ );
+ }
+ }
+
+ void _resolveFilesCall(MethodCall call) {
+ final args = call.arguments as Map;
+ final result = (args['result'] as List?)?.cast() ?? [];
+ final callId = args['callId']! as int;
+
+ _resolveFuture(callId, result.map(XFile.new).toList());
+ }
+
+ void _resolveFileCall(MethodCall call) {
+ final args = call.arguments as Map;
+ final result = args['result']! as String;
+ final callId = args['callId']! as int;
+
+ _resolveFuture(callId, XFile(result));
+ }
+
+ void _resolveDataCall(MethodCall call) {
+ final args = call.arguments as Map;
+ final result = args['result']! as List;
+ final callId = args['callId']! as int;
+
+ _resolveFuture(callId, Uint8List.fromList(result));
+ }
+
+ void _resolveError(MethodCall call) {
+ final args = call.arguments as Map;
+ final error = args['result']!;
+ final callId = args['callId']! as int;
+
+ _resolveFuture(callId, error is Exception ? error : Exception(error));
+ }
+
+ void _resolveFuture(int callId, Object value) {
+ if (value is Exception) {
+ _futures[callId]?.completeError(value);
+ } else {
+ _futures[callId]?.complete(value);
+ }
+ _futures.remove(callId);
+ }
+
+ (Completer, int) _createCompleterAndCallId() {
+ final completer = Completer();
+ final callId = _nextCallId++;
+
+ _futures[callId] = completer;
+
+ return (completer, callId);
+ }
+
+ int _getTimeMsValue(int? timeMs) => defaultTargetPlatform == TargetPlatform.android ? timeMs ?? -1 : timeMs ?? 0;
+
+ @override
+ Future> thumbnailFiles({
+ required List videos,
+ required Map? headers,
+ required String? thumbnailPath,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) async {
+ if (defaultTargetPlatform == TargetPlatform.iOS) {
+ final results = [];
+
+ for (final video in videos) {
+ results.add(
+ await thumbnailFile(
+ video: video,
+ headers: headers,
+ thumbnailPath: thumbnailPath,
+ imageFormat: imageFormat,
+ maxHeight: maxHeight,
+ maxWidth: maxWidth,
+ timeMs: timeMs,
+ quality: quality,
+ ),
+ );
+ }
+
+ return results;
+ }
+
+ final (completer, callId) = _createCompleterAndCallId>();
+
+ final reqMap = {
+ 'callId': callId,
+ 'videos': videos,
+ 'headers': headers,
+ 'path': thumbnailPath,
+ 'format': imageFormat.index,
+ 'maxh': maxHeight,
+ 'maxw': maxWidth,
+ 'timeMs': _getTimeMsValue(timeMs),
+ 'quality': quality,
+ };
+
+ try {
+ final result = await methodChannel.invokeMethod('files', reqMap);
+ if (result != true) {
+ _resolveFuture(callId, result);
+ }
+ } catch (_) {
+ // Drop the pending completer so it doesn't linger in `_futures`.
+ _futures.remove(callId);
+ rethrow;
+ }
+
+ return completer.future;
+ }
+
+ @override
+ Future thumbnailFile({
+ required String video,
+ required Map? headers,
+ required String? thumbnailPath,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) async {
+ final (completer, callId) = _createCompleterAndCallId();
+
+ final reqMap = {
+ 'callId': callId,
+ 'video': video,
+ 'headers': headers,
+ 'path': thumbnailPath,
+ 'format': imageFormat.index,
+ 'maxh': maxHeight,
+ 'maxw': maxWidth,
+ 'timeMs': _getTimeMsValue(timeMs),
+ 'quality': quality,
+ };
+
+ try {
+ final result = await methodChannel.invokeMethod('file', reqMap);
+ if (result != true) {
+ // iOS returns the written file path directly; wrap it as an [XFile] to
+ // satisfy the Future contract (Android replies via 'result#file').
+ _resolveFuture(callId, XFile(result as String));
+ }
+ } catch (_) {
+ _futures.remove(callId);
+ rethrow;
+ }
+
+ return completer.future;
+ }
+
+ @override
+ Future thumbnailData({
+ required String video,
+ required Map? headers,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) async {
+ final (completer, callId) = _createCompleterAndCallId();
+
+ final reqMap = {
+ 'callId': callId,
+ 'video': video,
+ 'headers': headers,
+ 'format': imageFormat.index,
+ 'maxh': maxHeight,
+ 'maxw': maxWidth,
+ 'timeMs': _getTimeMsValue(timeMs),
+ 'quality': quality,
+ };
+
+ try {
+ final result = await methodChannel.invokeMethod('data', reqMap);
+ if (result != true) {
+ _resolveFuture(callId, result);
+ }
+ } catch (_) {
+ _futures.remove(callId);
+ rethrow;
+ }
+
+ return completer.future;
+ }
+}
diff --git a/packages/stream_thumbnail/lib/src/stream_thumbnail_platform.dart b/packages/stream_thumbnail/lib/src/stream_thumbnail_platform.dart
new file mode 100644
index 00000000..514b64bd
--- /dev/null
+++ b/packages/stream_thumbnail/lib/src/stream_thumbnail_platform.dart
@@ -0,0 +1,75 @@
+// Platform-interface boilerplate (token, default instance, and the
+// UnimplementedError stubs that platform implementations override).
+// coverage:ignore-file
+import 'dart:typed_data';
+
+import 'package:cross_file/cross_file.dart';
+import 'package:plugin_platform_interface/plugin_platform_interface.dart';
+
+import 'stream_thumbnail_format.dart';
+import 'stream_thumbnail_method_channel.dart';
+
+/// The interface that platform-specific implementations of
+/// `stream_thumbnail` must implement.
+abstract class StreamThumbnailPlatform extends PlatformInterface {
+ /// Constructs a StreamThumbnailPlatform.
+ StreamThumbnailPlatform() : super(token: _token);
+
+ static final _token = Object();
+
+ static StreamThumbnailPlatform _instance = MethodChannelStreamThumbnail();
+
+ /// The default instance of [StreamThumbnailPlatform] to use.
+ ///
+ /// Defaults to [MethodChannelStreamThumbnail].
+ static StreamThumbnailPlatform get instance => _instance;
+
+ /// Platform-specific implementations should set this with their own
+ /// platform-specific class that extends [StreamThumbnailPlatform] when
+ /// they register themselves.
+ static set instance(StreamThumbnailPlatform instance) {
+ PlatformInterface.verifyToken(instance, _token);
+ _instance = instance;
+ }
+
+ /// Generates a thumbnail file for each of the given `videos`.
+ Future> thumbnailFiles({
+ required List videos,
+ required Map? headers,
+ required String? thumbnailPath,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) {
+ throw UnimplementedError('thumbnailFiles() has not been implemented.');
+ }
+
+ /// Generates a thumbnail file for the given `video`.
+ Future thumbnailFile({
+ required String video,
+ required Map? headers,
+ required String? thumbnailPath,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) {
+ throw UnimplementedError('thumbnailFile() has not been implemented.');
+ }
+
+ /// Generates a thumbnail for the given `video` as in-memory bytes.
+ Future thumbnailData({
+ required String video,
+ required Map? headers,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) {
+ throw UnimplementedError('thumbnailData() has not been implemented.');
+ }
+}
diff --git a/packages/stream_thumbnail/lib/stream_thumbnail.dart b/packages/stream_thumbnail/lib/stream_thumbnail.dart
new file mode 100644
index 00000000..43e01693
--- /dev/null
+++ b/packages/stream_thumbnail/lib/stream_thumbnail.dart
@@ -0,0 +1,11 @@
+/// A Flutter plugin for creating a thumbnail from a local video file or from a
+/// video URL.
+///
+/// To use, import
+/// `package:stream_thumbnail/stream_thumbnail.dart`.
+library;
+
+export 'package:cross_file/cross_file.dart' show XFile;
+
+export 'src/stream_thumbnail.dart' show StreamThumbnail;
+export 'src/stream_thumbnail_format.dart' show StreamThumbnailFormat;
diff --git a/packages/stream_thumbnail/lib/stream_thumbnail_web.dart b/packages/stream_thumbnail/lib/stream_thumbnail_web.dart
new file mode 100644
index 00000000..f08c0abe
--- /dev/null
+++ b/packages/stream_thumbnail/lib/stream_thumbnail_web.dart
@@ -0,0 +1,336 @@
+// The web implementation is browser/DOM glue that requires a real browser to
+// exercise, so it is validated via the example app rather than unit tests.
+// coverage:ignore-file
+import 'dart:async';
+import 'dart:js_interop';
+import 'dart:math' as math;
+
+import 'package:cross_file/cross_file.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_web_plugins/flutter_web_plugins.dart';
+import 'package:web/web.dart' as web;
+
+import 'src/stream_thumbnail_format.dart';
+import 'src/stream_thumbnail_platform.dart';
+
+// An error code value to error name Map.
+// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code
+const _kErrorValueToErrorName = {
+ 1: 'MEDIA_ERR_ABORTED',
+ 2: 'MEDIA_ERR_NETWORK',
+ 3: 'MEDIA_ERR_DECODE',
+ 4: 'MEDIA_ERR_SRC_NOT_SUPPORTED',
+};
+
+// An error code value to description Map.
+// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/code
+const _kErrorValueToErrorDescription = {
+ 1: 'The user canceled the fetching of the video.',
+ 2: 'A network error occurred while fetching the video, despite having previously been available.',
+ 3: 'An error occurred while trying to decode the video, despite having previously been determined to be usable.',
+ 4: 'The video has been found to be unsuitable (missing or in a format not supported by your browser).',
+};
+
+// The default error message, when the error is an empty string
+// See: https://developer.mozilla.org/en-US/docs/Web/API/MediaError/message
+const _kDefaultErrorMessage = 'No further diagnostic information can be determined or provided.';
+
+/// A web implementation of the [StreamThumbnailPlatform].
+class StreamThumbnailWeb extends StreamThumbnailPlatform {
+ /// Constructs a [StreamThumbnailWeb].
+ StreamThumbnailWeb();
+
+ /// Registers this class as the default [StreamThumbnailPlatform] on web.
+ static void registerWith(Registrar registrar) {
+ StreamThumbnailPlatform.instance = StreamThumbnailWeb();
+ }
+
+ @override
+ Future> thumbnailFiles({
+ required List videos,
+ required Map? headers,
+ required String? thumbnailPath,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) async {
+ final blobs = [];
+
+ for (final video in videos) {
+ blobs.add(
+ await _createThumbnail(
+ videoSrc: video,
+ headers: headers,
+ imageFormat: imageFormat,
+ maxHeight: maxHeight,
+ maxWidth: maxWidth,
+ timeMs: timeMs ?? 0,
+ quality: quality,
+ ),
+ );
+ }
+
+ return blobs
+ .map(
+ (blob) => XFile(web.URL.createObjectURL(blob), mimeType: blob.type),
+ )
+ .toList();
+ }
+
+ @override
+ Future thumbnailFile({
+ required String video,
+ required Map? headers,
+ required String? thumbnailPath,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) async {
+ final blob = await _createThumbnail(
+ videoSrc: video,
+ headers: headers,
+ imageFormat: imageFormat,
+ maxHeight: maxHeight,
+ maxWidth: maxWidth,
+ timeMs: timeMs ?? 0,
+ quality: quality,
+ );
+
+ return XFile(web.URL.createObjectURL(blob), mimeType: blob.type);
+ }
+
+ @override
+ Future thumbnailData({
+ required String video,
+ required Map? headers,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) async {
+ final blob = await _createThumbnail(
+ videoSrc: video,
+ headers: headers,
+ imageFormat: imageFormat,
+ maxHeight: maxHeight,
+ maxWidth: maxWidth,
+ timeMs: timeMs ?? 0,
+ quality: quality,
+ );
+ final path = web.URL.createObjectURL(blob);
+ final file = XFile(path, mimeType: blob.type);
+ final bytes = await file.readAsBytes();
+ web.URL.revokeObjectURL(path);
+
+ return bytes;
+ }
+
+ Future _createThumbnail({
+ required String videoSrc,
+ required Map? headers,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ required int quality,
+ int timeMs = 0,
+ }) async {
+ final completer = Completer();
+
+ final video = web.document.createElement('video') as web.HTMLVideoElement;
+ final timeSec = math.max(timeMs / 1000, 0);
+ final fetchVideo = headers != null && headers.isNotEmpty;
+
+ video.addEventListener(
+ 'loadedmetadata',
+ (web.Event event) {
+ video.currentTime = timeSec;
+
+ if (fetchVideo) {
+ web.URL.revokeObjectURL(video.src);
+ }
+ }.toJS,
+ );
+
+ video.addEventListener(
+ 'seeked',
+ (web.Event e) {
+ if (!completer.isCompleted) {
+ final canvas = web.document.createElement('canvas') as web.HTMLCanvasElement;
+ final ctx = canvas.getContext('2d')! as web.CanvasRenderingContext2D;
+
+ if (maxWidth == 0 && maxHeight == 0) {
+ canvas
+ ..width = video.videoWidth
+ ..height = video.videoHeight;
+ ctx.drawImage(video, 0, 0);
+ } else {
+ var width = maxWidth;
+ var height = maxHeight;
+ final aspectRatio = video.videoWidth / video.videoHeight;
+ if (width == 0) {
+ width = (height * aspectRatio).round();
+ } else if (height == 0) {
+ height = (width / aspectRatio).round();
+ }
+
+ final inputAspectRatio = width / height;
+ if (aspectRatio > inputAspectRatio) {
+ height = (width / aspectRatio).round();
+ } else {
+ width = (height * aspectRatio).round();
+ }
+
+ canvas
+ ..width = width
+ ..height = height;
+ ctx.drawImage(video, 0, 0, width, height);
+ }
+
+ try {
+ canvas.toBlob(
+ (web.Blob? blob) {
+ if (completer.isCompleted) return;
+ if (blob == null) {
+ completer.completeError(
+ PlatformException(
+ code: 'CANVAS_EXPORT_ERROR',
+ message: 'Canvas could not be exported to a blob.',
+ ),
+ );
+ } else {
+ completer.complete(blob);
+ }
+ }.toJS,
+ _imageFormatToCanvasFormat(imageFormat),
+ (quality / 100).toJS,
+ );
+ } catch (e, s) {
+ completer.completeError(
+ PlatformException(
+ code: 'CANVAS_EXPORT_ERROR',
+ details: e,
+ stacktrace: s.toString(),
+ ),
+ s,
+ );
+ }
+ }
+ }.toJS,
+ );
+
+ video.addEventListener(
+ 'error',
+ (web.Event e) {
+ // The Event itself (e) doesn't contain info about the actual error.
+ // We need to look at the HTMLMediaElement.error.
+ // See: https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/error
+ if (!completer.isCompleted) {
+ final error = video.error!;
+ completer.completeError(
+ PlatformException(
+ code: _kErrorValueToErrorName[error.code]!,
+ message: error.message != '' ? error.message : _kDefaultErrorMessage,
+ details: _kErrorValueToErrorDescription[error.code],
+ ),
+ );
+ }
+ }.toJS,
+ );
+
+ if (fetchVideo) {
+ try {
+ final blob = await _fetchVideoByHeaders(
+ videoSrc: videoSrc,
+ headers: headers,
+ );
+
+ video.src = web.URL.createObjectURL(blob);
+ } catch (e, s) {
+ completer.completeError(e, s);
+ }
+ } else {
+ video
+ ..crossOrigin = 'Anonymous'
+ ..src = videoSrc;
+ }
+
+ // Bound a source that never fires `seeked`/`error` so the future can't hang
+ // (and retain the video element) forever, and release the media element once
+ // the result settles.
+ return completer.future
+ .timeout(
+ const Duration(seconds: 30),
+ onTimeout: () => throw PlatformException(
+ code: 'TIMEOUT',
+ message: 'Timed out generating a thumbnail for the video.',
+ ),
+ )
+ .whenComplete(() {
+ video
+ ..src = ''
+ ..load();
+ });
+ }
+
+ // Fetches the video using the given headers.
+ //
+ // To avoid reading the video's bytes into memory, the XMLHttpRequest
+ // responseType is set to 'blob'. This allows the blob to be stored in the
+ // browser's disk or memory cache.
+ Future _fetchVideoByHeaders({
+ required String videoSrc,
+ required Map headers,
+ }) {
+ final completer = Completer();
+
+ final xhr = web.XMLHttpRequest()
+ ..open('GET', videoSrc, true)
+ ..responseType = 'blob';
+ for (final entry in headers.entries) {
+ xhr.setRequestHeader(entry.key, entry.value);
+ }
+
+ xhr.addEventListener(
+ 'load',
+ (web.Event event) {
+ if (!completer.isCompleted) {
+ completer.complete(xhr.response! as web.Blob);
+ }
+ }.toJS,
+ );
+
+ xhr.addEventListener(
+ 'error',
+ (web.Event event) {
+ if (!completer.isCompleted) {
+ completer.completeError(
+ PlatformException(
+ code: 'VIDEO_FETCH_ERROR',
+ message: 'Status: ${xhr.statusText}',
+ ),
+ );
+ }
+ }.toJS,
+ );
+
+ xhr.send();
+
+ return completer.future;
+ }
+
+ String _imageFormatToCanvasFormat(StreamThumbnailFormat imageFormat) {
+ switch (imageFormat) {
+ case StreamThumbnailFormat.jpeg:
+ return 'image/jpeg';
+ case StreamThumbnailFormat.png:
+ return 'image/png';
+ case StreamThumbnailFormat.webp:
+ return 'image/webp';
+ }
+ }
+}
diff --git a/packages/stream_thumbnail/pubspec.yaml b/packages/stream_thumbnail/pubspec.yaml
new file mode 100644
index 00000000..5351e5d6
--- /dev/null
+++ b/packages/stream_thumbnail/pubspec.yaml
@@ -0,0 +1,35 @@
+name: stream_thumbnail
+description: A Flutter plugin for creating a thumbnail from a local video file or from a video URL.
+version: 0.0.1
+homepage: https://github.com/GetStream/stream-core-flutter
+repository: https://github.com/GetStream/stream-core-flutter
+
+environment:
+ sdk: ^3.10.0
+ flutter: ">=3.38.1"
+
+dependencies:
+ cross_file: ^0.3.4+2
+ flutter:
+ sdk: flutter
+ flutter_web_plugins:
+ sdk: flutter
+ plugin_platform_interface: ^2.1.8
+ web: ^1.1.1
+
+dev_dependencies:
+ flutter_test:
+ sdk: flutter
+
+flutter:
+ plugin:
+ implements: stream_thumbnail
+ platforms:
+ android:
+ package: io.getstream.stream_thumbnail
+ pluginClass: StreamThumbnailPlugin
+ ios:
+ pluginClass: StreamThumbnailPlugin
+ web:
+ pluginClass: StreamThumbnailWeb
+ fileName: stream_thumbnail_web.dart
diff --git a/packages/stream_thumbnail/test/stream_thumbnail_test.dart b/packages/stream_thumbnail/test/stream_thumbnail_test.dart
new file mode 100644
index 00000000..7af3bb7d
--- /dev/null
+++ b/packages/stream_thumbnail/test/stream_thumbnail_test.dart
@@ -0,0 +1,264 @@
+import 'package:cross_file/cross_file.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/services.dart';
+import 'package:flutter_test/flutter_test.dart';
+import 'package:stream_thumbnail/src/stream_thumbnail.dart';
+import 'package:stream_thumbnail/src/stream_thumbnail_format.dart';
+import 'package:stream_thumbnail/src/stream_thumbnail_method_channel.dart';
+import 'package:stream_thumbnail/src/stream_thumbnail_platform.dart';
+
+/// A fake platform that records the last call it received and returns canned
+/// results. Extending [StreamThumbnailPlatform] inherits its token, so it
+/// can be installed as the active instance without the mock mixin.
+class FakeStreamThumbnailPlatform extends StreamThumbnailPlatform {
+ final _data = Uint8List.fromList([1, 2, 3]);
+ final _file = XFile('/thumb.png');
+ var _filesCalled = false;
+
+ /// The canned bytes returned by [thumbnailData].
+ Uint8List get data => _data;
+
+ /// The canned file returned by [thumbnailFile].
+ XFile get file => _file;
+
+ /// Whether [thumbnailFiles] was invoked.
+ bool get filesCalled => _filesCalled;
+
+ /// The arguments captured from the most recent [thumbnailData] call.
+ Map? lastDataCall;
+
+ /// The arguments captured from the most recent [thumbnailFile] call.
+ Map? lastFileCall;
+
+ @override
+ Future thumbnailData({
+ required String video,
+ required Map? headers,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) async {
+ lastDataCall = {
+ 'video': video,
+ 'headers': headers,
+ 'imageFormat': imageFormat,
+ 'maxHeight': maxHeight,
+ 'maxWidth': maxWidth,
+ 'timeMs': timeMs,
+ 'quality': quality,
+ };
+ return _data;
+ }
+
+ @override
+ Future thumbnailFile({
+ required String video,
+ required Map? headers,
+ required String? thumbnailPath,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) async {
+ lastFileCall = {
+ 'video': video,
+ 'thumbnailPath': thumbnailPath,
+ 'imageFormat': imageFormat,
+ };
+ return _file;
+ }
+
+ @override
+ Future> thumbnailFiles({
+ required List videos,
+ required Map? headers,
+ required String? thumbnailPath,
+ required StreamThumbnailFormat imageFormat,
+ required int maxHeight,
+ required int maxWidth,
+ int? timeMs,
+ required int quality,
+ }) async {
+ _filesCalled = true;
+ return [];
+ }
+}
+
+/// Installs a fake platform as the active instance and restores the original
+/// when the test finishes.
+FakeStreamThumbnailPlatform useFakePlatform() {
+ final original = StreamThumbnailPlatform.instance;
+ final fake = FakeStreamThumbnailPlatform();
+ StreamThumbnailPlatform.instance = fake;
+ addTearDown(() => StreamThumbnailPlatform.instance = original);
+ return fake;
+}
+
+void main() {
+ TestWidgetsFlutterBinding.ensureInitialized();
+
+ group('StreamThumbnailFormat', () {
+ // The wire protocol sends `imageFormat.index` to the native side, where the
+ // int maps to jpg/png/webp. This order is a cross-language contract.
+ test('exposes JPEG, PNG and WEBP at indices 0, 1 and 2', () {
+ expect(StreamThumbnailFormat.values, [
+ StreamThumbnailFormat.jpeg,
+ StreamThumbnailFormat.png,
+ StreamThumbnailFormat.webp,
+ ]);
+ expect(StreamThumbnailFormat.jpeg.index, 0);
+ expect(StreamThumbnailFormat.png.index, 1);
+ expect(StreamThumbnailFormat.webp.index, 2);
+ });
+ });
+
+ group('StreamThumbnail', () {
+ test('thumbnailData forwards to the platform and returns its bytes', () async {
+ final fake = useFakePlatform();
+
+ final result = await StreamThumbnail.thumbnailData(video: 'a.mp4');
+
+ expect(result, same(fake.data));
+ });
+
+ test('thumbnailData applies the documented default options', () async {
+ final fake = useFakePlatform();
+
+ await StreamThumbnail.thumbnailData(video: 'a.mp4');
+
+ expect(fake.lastDataCall, {
+ 'video': 'a.mp4',
+ 'headers': null,
+ 'imageFormat': StreamThumbnailFormat.png,
+ 'maxHeight': 0,
+ 'maxWidth': 0,
+ 'timeMs': null,
+ 'quality': 10,
+ });
+ });
+
+ test('thumbnailFile forwards to the platform and returns its file', () async {
+ final fake = useFakePlatform();
+
+ final result = await StreamThumbnail.thumbnailFile(video: 'a.mp4');
+
+ expect(result, same(fake.file));
+ expect(fake.lastFileCall?['video'], 'a.mp4');
+ });
+
+ test('thumbnailFiles returns an empty list without hitting the platform for no videos', () async {
+ final fake = useFakePlatform();
+
+ final result = await StreamThumbnail.thumbnailFiles(videos: []);
+
+ expect(result, isEmpty);
+ expect(fake.filesCalled, isFalse);
+ });
+
+ test('thumbnailData rejects an empty video path', () {
+ useFakePlatform();
+
+ expect(
+ () => StreamThumbnail.thumbnailData(video: ''),
+ throwsA(isA()),
+ );
+ });
+ });
+
+ group('MethodChannelStreamThumbnail', () {
+ const channel = MethodChannelStreamThumbnail.methodChannel;
+ final messenger = TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
+
+ /// Intercepts outgoing calls on the plugin channel, recording them and
+ /// replying with [reply].
+ List mockChannel(Object? reply) {
+ final calls = [];
+ messenger.setMockMethodCallHandler(channel, (call) async {
+ calls.add(call);
+ return reply;
+ });
+ addTearDown(() => messenger.setMockMethodCallHandler(channel, null));
+ return calls;
+ }
+
+ test('thumbnailData invokes "data" with the encoded request and returns the bytes', () async {
+ final data = Uint8List.fromList([9, 8, 7]);
+ final calls = mockChannel(data);
+
+ final result = await MethodChannelStreamThumbnail().thumbnailData(
+ video: 'a.mp4',
+ headers: null,
+ imageFormat: StreamThumbnailFormat.webp,
+ maxHeight: 10,
+ maxWidth: 20,
+ timeMs: 500,
+ quality: 80,
+ );
+
+ expect(result, data);
+ expect(calls.single.method, 'data');
+ final args = calls.single.arguments as Map;
+ expect(args['video'], 'a.mp4');
+ expect(args['format'], StreamThumbnailFormat.webp.index);
+ expect(args['maxh'], 10);
+ expect(args['maxw'], 20);
+ expect(args['timeMs'], 500);
+ expect(args['quality'], 80);
+ });
+
+ test('thumbnailFile wraps a directly-returned path in an XFile', () async {
+ // iOS replies with the written file path directly (Android uses the
+ // 'result#file' reverse callback instead).
+ mockChannel('/tmp/thumb.png');
+
+ final result = await MethodChannelStreamThumbnail().thumbnailFile(
+ video: 'a.mp4',
+ headers: null,
+ thumbnailPath: null,
+ imageFormat: StreamThumbnailFormat.png,
+ maxHeight: 0,
+ maxWidth: 0,
+ quality: 10,
+ );
+
+ expect(result.path, '/tmp/thumb.png');
+ });
+
+ test('a null timeMs is sent as -1 on Android', () async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.android;
+ addTearDown(() => debugDefaultTargetPlatformOverride = null);
+ final calls = mockChannel(Uint8List(0));
+
+ await MethodChannelStreamThumbnail().thumbnailData(
+ video: 'a.mp4',
+ headers: null,
+ imageFormat: StreamThumbnailFormat.png,
+ maxHeight: 0,
+ maxWidth: 0,
+ quality: 10,
+ );
+
+ expect((calls.single.arguments as Map)['timeMs'], -1);
+ });
+
+ test('a null timeMs is sent as 0 on non-Android platforms', () async {
+ debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+ addTearDown(() => debugDefaultTargetPlatformOverride = null);
+ final calls = mockChannel(Uint8List(0));
+
+ await MethodChannelStreamThumbnail().thumbnailData(
+ video: 'a.mp4',
+ headers: null,
+ imageFormat: StreamThumbnailFormat.png,
+ maxHeight: 0,
+ maxWidth: 0,
+ quality: 10,
+ );
+
+ expect((calls.single.arguments as Map)['timeMs'], 0);
+ });
+ });
+}