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); + }); + }); +}